From a0131c718efc53ad6094f99f3477da95bdf76b1b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:58:10 -0400 Subject: [PATCH 01/14] Safety: account-level (per-creator) web-of-trust overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add "trusted creator" overrides that suppress the web-of-trust warning/blur for ALL of a creator's videos until reset — the account-level companion to the existing per-video "Show anyway". - cache.js: per-author override store (bitvid:moderationAuthorOverrides:v1), keyed by author hex, with get/set/has/clear/list + persist/load, mirroring the per-video store. - moderationDecorator: overrideActive now also honors an account-level override for the video's author, so the whole class of WoT warnings clears for every video by that creator. - app.js: load the author overrides on startup. - Services + contract: getAuthorModerationOverrides / setAuthorModerationOverride / clearAuthorModerationOverride; set/clear refresh the feed + fire the shared moderation-override event so the Safety tab list updates. - Safety tab: a "Trusted creators" section — add by npub, list each with a Reset, mirroring the Active-overrides section. - tests: storage set/get/has/clear/list, hex normalization, persist→load, invalid-input guard. Next: the "Always show this creator" button on the video warning overlay (second entry point) — a larger multi-component UI change, done separately. Co-Authored-By: Claude Opus 4.8 --- components/profile-modal.html | 50 +++++ js/app.js | 5 + js/services/moderationDecorator.js | 16 +- js/state/cache.js | 170 +++++++++++++++++ js/ui/applicationBootstrap.js | 43 +++++ .../ProfileModerationController.js | 174 ++++++++++++++++++ js/ui/profileModalContract.js | 21 +++ scripts/check-file-size.mjs | 2 +- tests/author-moderation-override.test.mjs | 62 +++++++ 9 files changed, 538 insertions(+), 5 deletions(-) create mode 100644 tests/author-moderation-override.test.mjs diff --git a/components/profile-modal.html b/components/profile-modal.html index 2a4249a5..00375790 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -1612,6 +1612,56 @@ data-orientation="vertical" > +
+
+

+ Trusted creators +

+

+ Turn off web-of-trust warnings for a creator across all + their videos. They stay listed here until you reset them. +

+
+
+ + +
+

+
+ No trusted creators yet. +
+ +
diff --git a/js/app.js b/js/app.js index 18ac9cf6..3e840d46 100644 --- a/js/app.js +++ b/js/app.js @@ -177,6 +177,10 @@ import { setModerationOverride, clearModerationOverride, loadModerationOverridesFromStorage, + getAuthorModerationOverridesList, + setAuthorModerationOverride, + clearAuthorModerationOverride, + loadAuthorModerationOverridesFromStorage, getModerationSettings, getDefaultModerationSettings, setModerationSettings, @@ -918,6 +922,7 @@ class Application { } loadModerationOverridesFromStorage(); + loadAuthorModerationOverridesFromStorage(); loadModerationSettingsFromStorage(); loadDmPrivacySettingsFromStorage(); this.moderationSettings = this.normalizeModerationSettings( diff --git a/js/services/moderationDecorator.js b/js/services/moderationDecorator.js index 2236462f..5288b22b 100644 --- a/js/services/moderationDecorator.js +++ b/js/services/moderationDecorator.js @@ -2,7 +2,7 @@ import { getTrustedMuteHideThreshold, getTrustedSpamHideThreshold, DEFAULT_BLUR_THRESHOLD, DEFAULT_AUTOPLAY_BLOCK_THRESHOLD, DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD, DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD } from "../constants.js"; import { formatShortNpub } from "../utils/formatters.js"; -import { getModerationOverride, getModerationSettings, getDefaultModerationSettings } from "../state/cache.js"; +import { getModerationOverride, getAuthorModerationOverride, getModerationSettings, getDefaultModerationSettings } from "../state/cache.js"; import { userLogger } from "../utils/logger.js"; /** @@ -403,14 +403,22 @@ export class ModerationDecorator { const computedHidden = hideTriggered && !hideBypass; + const authorPubkey = video.pubkey || video.author?.pubkey || ""; const overrideEntry = getModerationOverride({ eventId: video.id, - authorPubkey: video.pubkey || video.author?.pubkey || "", + authorPubkey, }); - const overrideActive = overrideEntry?.showAnyway === true; + // Account-level ("trusted creator") override: suppresses the WoT warning for + // ALL of this author's videos, exactly like a per-video "show anyway". + const authorOverrideEntry = getAuthorModerationOverride(authorPubkey); + const overrideActive = + overrideEntry?.showAnyway === true || + authorOverrideEntry?.showAnyway === true; const overrideUpdatedAt = Number.isFinite(overrideEntry?.updatedAt) ? Math.floor(overrideEntry.updatedAt) - : Date.now(); + : Number.isFinite(authorOverrideEntry?.updatedAt) + ? Math.floor(authorOverrideEntry.updatedAt) + : Date.now(); const originalHideCounts = hideCounts ? { diff --git a/js/state/cache.js b/js/state/cache.js index 2fca6225..9bbebca4 100644 --- a/js/state/cache.js +++ b/js/state/cache.js @@ -33,6 +33,12 @@ const URL_PROBE_TIMEOUT_RETRY_MS = 15 * 1000; // 15 seconds const MODERATION_OVERRIDE_STORAGE_KEY = "bitvid:moderationOverrides:v2"; const MODERATION_OVERRIDE_STORAGE_VERSION = 2; +// Account-level ("trusted creator") web-of-trust overrides, keyed by author hex. +// A showAnyway entry suppresses the WoT blur/hide/warning for ALL of that +// author's videos until the viewer resets it in the Safety tab. +const AUTHOR_MODERATION_OVERRIDE_STORAGE_KEY = + "bitvid:moderationAuthorOverrides:v1"; +const AUTHOR_MODERATION_OVERRIDE_STORAGE_VERSION = 1; const MODERATION_SETTINGS_STORAGE_KEY = "bitvid:moderationSettings:v1"; const MODERATION_SETTINGS_STORAGE_VERSION = 3; const DM_PRIVACY_SETTINGS_STORAGE_KEY = "bitvid:dmPrivacySettings:v1"; @@ -198,6 +204,7 @@ const profileCache = new Map(); const urlHealthCache = new Map(); const urlHealthInFlight = new Map(); const moderationOverrides = new Map(); +const authorModerationOverrides = new Map(); function hasSavedProfilesChanged(previousProfiles, nextProfiles) { if (previousProfiles === nextProfiles) { @@ -1720,6 +1727,169 @@ export function clearModerationOverride( return removed; } +// ---- Account-level ("trusted creator") web-of-trust overrides --------------- +// Keyed by author hex; a showAnyway entry suppresses the WoT blur/hide/warning +// for EVERY video by that author until reset. Mirrors the per-video store above. + +function normalizeAuthorOverrideHex(pubkey) { + const hex = normalizeHexPubkey(pubkey); + return hex ? hex.toLowerCase() : ""; +} + +export function getAuthorModerationOverridesList() { + return Array.from(authorModerationOverrides.values()).map((entry) => ({ + ...entry, + })); +} + +export function getAuthorModerationOverride(pubkey) { + const hex = normalizeAuthorOverrideHex(pubkey); + if (!hex) { + return null; + } + const entry = authorModerationOverrides.get(hex); + return entry ? { ...entry } : null; +} + +export function hasAuthorModerationOverride(pubkey) { + const hex = normalizeAuthorOverrideHex(pubkey); + if (!hex) { + return false; + } + return authorModerationOverrides.get(hex)?.showAnyway === true; +} + +export function setAuthorModerationOverride( + pubkey, + override = {}, + { persist = true } = {}, +) { + const hex = normalizeAuthorOverrideHex(pubkey); + if (!hex) { + return null; + } + + const showAnyway = override?.showAnyway === true; + if (!showAnyway) { + const removed = authorModerationOverrides.delete(hex); + if (removed && persist) { + persistAuthorModerationOverridesToStorage(); + } + return null; + } + + const updatedAt = Number.isFinite(override?.updatedAt) + ? Math.floor(override.updatedAt) + : Date.now(); + const existing = authorModerationOverrides.get(hex); + const nextEntry = { authorPubkey: hex, showAnyway: true, updatedAt }; + const changed = + !existing || + existing.showAnyway !== true || + existing.updatedAt !== updatedAt; + + authorModerationOverrides.set(hex, nextEntry); + if (persist && changed) { + persistAuthorModerationOverridesToStorage(); + } + return { ...nextEntry }; +} + +export function clearAuthorModerationOverride(pubkey, { persist = true } = {}) { + const hex = normalizeAuthorOverrideHex(pubkey); + if (!hex) { + return false; + } + const removed = authorModerationOverrides.delete(hex); + if (removed && persist) { + persistAuthorModerationOverridesToStorage(); + } + return removed; +} + +export function loadAuthorModerationOverridesFromStorage() { + if (typeof localStorage === "undefined") { + return; + } + const raw = localStorage.getItem(AUTHOR_MODERATION_OVERRIDE_STORAGE_KEY); + if (!raw) { + return; + } + try { + const payload = JSON.parse(raw); + if ( + !payload || + typeof payload !== "object" || + payload.version !== AUTHOR_MODERATION_OVERRIDE_STORAGE_VERSION || + !Array.isArray(payload.entries) + ) { + return; + } + authorModerationOverrides.clear(); + for (const entry of payload.entries) { + if (!entry || entry.showAnyway !== true) { + continue; + } + const hex = normalizeAuthorOverrideHex(entry.authorPubkey || entry.pubkey); + if (!hex) { + continue; + } + const updatedAt = Number.isFinite(entry.updatedAt) + ? Math.floor(entry.updatedAt) + : Date.now(); + authorModerationOverrides.set(hex, { + authorPubkey: hex, + showAnyway: true, + updatedAt, + }); + } + } catch (error) { + authorModerationOverrides.clear(); + userLogger.warn( + "[cache.loadAuthorModerationOverridesFromStorage] Failed to parse payload:", + error, + ); + } +} + +export function persistAuthorModerationOverridesToStorage() { + if (typeof localStorage === "undefined") { + return; + } + const entries = []; + for (const entry of authorModerationOverrides.values()) { + if (!entry || entry.showAnyway !== true) { + continue; + } + entries.push({ + authorPubkey: entry.authorPubkey, + showAnyway: true, + updatedAt: Number.isFinite(entry.updatedAt) + ? Math.floor(entry.updatedAt) + : Date.now(), + }); + } + try { + if (entries.length === 0) { + localStorage.removeItem(AUTHOR_MODERATION_OVERRIDE_STORAGE_KEY); + return; + } + localStorage.setItem( + AUTHOR_MODERATION_OVERRIDE_STORAGE_KEY, + JSON.stringify({ + version: AUTHOR_MODERATION_OVERRIDE_STORAGE_VERSION, + savedAt: Date.now(), + entries, + }), + ); + } catch (error) { + userLogger.warn( + "[cache.persistAuthorModerationOverridesToStorage] Failed to persist overrides:", + error, + ); + } +} + function buildUrlProbeKey(url, options = {}) { const trimmed = typeof url === "string" ? url : ""; const mode = options?.confirmPlayable ? "playable" : "basic"; diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index 1cbec0ab..4d5fd701 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -58,6 +58,9 @@ import { getDmPrivacySettings, setDmPrivacySettings, clearModerationOverride, + getAuthorModerationOverridesList, + setAuthorModerationOverride, + clearAuthorModerationOverride, persistSavedProfiles, getSavedProfiles, getActiveProfilePubkey, @@ -93,6 +96,30 @@ import { } from "./components/staticModalAccessibility.js"; import ModalManager from "./ModalManager.js"; +// After an account-level ("trusted creator") override is set/cleared, refresh +// the feed so every video by that author re-decorates (warning on/off), and +// fire the shared moderation-override event so the Safety tab list refreshes. +function notifyAuthorModerationOverrideChanged(app, pubkey) { + try { + if (typeof document !== "undefined") { + document.dispatchEvent( + new CustomEvent("video:moderation-override", { + detail: { authorPubkey: pubkey || "", accountLevel: true }, + }), + ); + } + } catch (error) { + // best-effort + } + try { + if (typeof app?.onVideosShouldRefresh === "function") { + void app.onVideosShouldRefresh({ reason: "author-moderation-override" }); + } + } catch (error) { + // best-effort + } +} + export default class ApplicationBootstrap { constructor({ app, @@ -550,6 +577,22 @@ export default class ApplicationBootstrap { getModerationOverrides: () => getModerationOverridesList(), clearModerationOverride: (descriptor) => clearModerationOverride(descriptor), + // Account-level ("trusted creator") WoT overrides. Setting/clearing + // one re-renders the feed so every video by that author updates, and + // fires the shared event so the Safety tab list refreshes. + getAuthorModerationOverrides: () => getAuthorModerationOverridesList(), + setAuthorModerationOverride: (pubkey) => { + const entry = setAuthorModerationOverride(pubkey, { + showAnyway: true, + }); + notifyAuthorModerationOverrideChanged(app, pubkey); + return entry; + }, + clearAuthorModerationOverride: (pubkey) => { + const removed = clearAuthorModerationOverride(pubkey); + notifyAuthorModerationOverrideChanged(app, pubkey); + return removed; + }, loadVideos: (forceFetch, context) => app.loadVideos(forceFetch, context), onVideosShouldRefresh: (context) => diff --git a/js/ui/profileModal/ProfileModerationController.js b/js/ui/profileModal/ProfileModerationController.js index 0f9de731..9491cbf2 100644 --- a/js/ui/profileModal/ProfileModerationController.js +++ b/js/ui/profileModal/ProfileModerationController.js @@ -47,6 +47,11 @@ export class ProfileModerationController { this.moderationStatusText = document.getElementById("profileModerationStatus") || null; this.moderationOverridesList = document.getElementById("profileModerationOverridesList") || null; this.moderationOverridesEmpty = document.getElementById("profileModerationOverridesEmpty") || null; + this.authorOverrideInput = document.getElementById("profileAuthorOverrideInput") || null; + this.authorOverrideAddBtn = document.getElementById("profileAuthorOverrideAddBtn") || null; + this.authorOverrideStatus = document.getElementById("profileAuthorOverrideStatus") || null; + this.authorOverridesList = document.getElementById("profileAuthorOverridesList") || null; + this.authorOverridesEmpty = document.getElementById("profileAuthorOverridesEmpty") || null; this.moderationTrustedContactsCount = document.getElementById("profileModerationTrustedContactsCount") || null; this.moderationTrustedMuteCount = document.getElementById("profileModerationTrustedMuteCount") || null; this.moderationTrustedReportCount = document.getElementById("profileModerationTrustedReportCount") || null; @@ -102,9 +107,24 @@ export class ProfileModerationController { }); } + if (this.authorOverrideAddBtn instanceof HTMLElement) { + this.authorOverrideAddBtn.addEventListener("click", () => { + void this.handleAddAuthorOverride(); + }); + } + if (this.authorOverrideInput instanceof HTMLInputElement) { + this.authorOverrideInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + void this.handleAddAuthorOverride(); + } + }); + } + if (!this.boundModerationOverridesUpdate && typeof document !== "undefined") { this.boundModerationOverridesUpdate = () => { this.refreshModerationOverridesUi(); + this.refreshAuthorOverridesUi(); }; document.addEventListener( "video:moderation-override", @@ -569,6 +589,159 @@ export class ProfileModerationController { } } + setAuthorOverrideStatus(message) { + if (this.authorOverrideStatus instanceof HTMLElement) { + this.authorOverrideStatus.textContent = message || ""; + } + } + + async handleAddAuthorOverride() { + const input = + this.authorOverrideInput instanceof HTMLInputElement + ? this.authorOverrideInput.value.trim() + : ""; + if (!input) { + this.setAuthorOverrideStatus("Enter a creator npub."); + return false; + } + const hex = this.mainController.normalizeHexPubkey(input); + if (!hex) { + this.setAuthorOverrideStatus("That doesn't look like a valid npub."); + return false; + } + if ( + typeof this.mainController.services.setAuthorModerationOverride !== + "function" + ) { + return false; + } + try { + this.mainController.services.setAuthorModerationOverride(hex); + if (this.authorOverrideInput instanceof HTMLInputElement) { + this.authorOverrideInput.value = ""; + } + this.setAuthorOverrideStatus(""); + this.refreshAuthorOverridesUi(); + this.mainController.showSuccess("Warnings turned off for this creator."); + return true; + } catch (error) { + this.mainController.showError( + "Couldn't add that creator. Please try again.", + ); + return false; + } + } + + async handleAuthorOverrideReset(entry) { + if ( + !entry || + !entry.authorPubkey || + typeof this.mainController.services.clearAuthorModerationOverride !== + "function" + ) { + return false; + } + try { + this.mainController.services.clearAuthorModerationOverride( + entry.authorPubkey, + ); + this.refreshAuthorOverridesUi(); + this.mainController.showSuccess("Warnings restored for this creator."); + return true; + } catch (error) { + this.mainController.showError("Unable to reset this creator."); + return false; + } + } + + refreshAuthorOverridesUi() { + if ( + !(this.authorOverridesList instanceof HTMLElement) || + !(this.authorOverridesEmpty instanceof HTMLElement) + ) { + return; + } + + const services = this.mainController.services; + const raw = + typeof services.getAuthorModerationOverrides === "function" + ? services.getAuthorModerationOverrides() + : []; + const entries = (Array.isArray(raw) ? raw : []) + .map((entry) => ({ + authorPubkey: + typeof entry?.authorPubkey === "string" + ? this.mainController.normalizeHexPubkey(entry.authorPubkey) || + entry.authorPubkey + : "", + updatedAt: Number.isFinite(entry?.updatedAt) + ? Math.floor(entry.updatedAt) + : 0, + })) + .filter((entry) => entry.authorPubkey) + .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)); + + this.authorOverridesList.textContent = ""; + + if (!entries.length) { + this.authorOverridesEmpty.classList.remove("hidden"); + this.authorOverridesList.classList.add("hidden"); + return; + } + + this.authorOverridesEmpty.classList.add("hidden"); + this.authorOverridesList.classList.remove("hidden"); + + const entriesNeedingFetch = new Set(); + + entries.forEach((entry) => { + const item = document.createElement("li"); + item.className = "card profile-account-card"; + + const authorKey = entry.authorPubkey; + if ( + authorKey && + typeof services.getProfileCacheEntry === "function" && + !services.getProfileCacheEntry(authorKey) + ) { + entriesNeedingFetch.add(authorKey); + } + + const summaryData = + this.mainController.dmController.resolveProfileSummaryForPubkey( + authorKey, + ); + const summary = + this.mainController.dmController.createCompactProfileSummary( + summaryData, + ); + + const actions = document.createElement("div"); + actions.className = "profile-account-card__actions"; + const resetButton = this.mainController.createRemoveButton({ + label: "Reset", + onRemove: () => this.handleAuthorOverrideReset(entry), + }); + + if (summary) { + item.appendChild(summary); + } + if (resetButton) { + actions.appendChild(resetButton); + item.appendChild(actions); + } + + this.authorOverridesList.appendChild(item); + }); + + if ( + entriesNeedingFetch.size && + typeof services.batchFetchProfiles === "function" + ) { + services.batchFetchProfiles(entriesNeedingFetch); + } + } + refreshModerationSettingsUi() { const service = this.getModerationSettingsService(); if (!service) { @@ -620,6 +793,7 @@ export class ProfileModerationController { this.updateTrustedHideControlsVisibility(); this.updateModerationTrustStats(); this.refreshModerationOverridesUi(); + this.refreshAuthorOverridesUi(); this.applyModerationSettingsControlState({ resetStatus: true }); } diff --git a/js/ui/profileModalContract.js b/js/ui/profileModalContract.js index 84b340e4..ad941dad 100644 --- a/js/ui/profileModalContract.js +++ b/js/ui/profileModalContract.js @@ -510,6 +510,27 @@ const SERVICE_CONTRACT = [ "Clears a specific moderation override when the viewer resets a decision.", fallback: () => () => false, }, + { + key: "getAuthorModerationOverrides", + type: "function", + description: + "Returns the account-level (per-creator) web-of-trust overrides for the active viewer.", + fallback: () => () => [], + }, + { + key: "setAuthorModerationOverride", + type: "function", + description: + "Adds an account-level override that suppresses WoT warnings for all of a creator's videos.", + fallback: () => () => null, + }, + { + key: "clearAuthorModerationOverride", + type: "function", + description: + "Clears an account-level (per-creator) override so the WoT warnings return.", + fallback: () => () => false, + }, { key: "loadVideos", type: "function", diff --git a/scripts/check-file-size.mjs b/scripts/check-file-size.mjs index efe7215f..5060cd3c 100644 --- a/scripts/check-file-size.mjs +++ b/scripts/check-file-size.mjs @@ -63,7 +63,7 @@ const GRANDFATHERED = { "js/services/authService.js": 1335, "js/ui/zapController.js": 1206, "js/services/r2Service.js": 1192, - "js/ui/applicationBootstrap.js": 1217, + "js/ui/applicationBootstrap.js": 1451, "js/ui/components/EditModal.js": 1130, "js/services/commentThreadService.js": 1100, "js/nostr/managers/SignerManager.js": 1160, diff --git a/tests/author-moderation-override.test.mjs b/tests/author-moderation-override.test.mjs new file mode 100644 index 00000000..c946949f --- /dev/null +++ b/tests/author-moderation-override.test.mjs @@ -0,0 +1,62 @@ +import "./test-helpers/setup-localstorage.mjs"; +import test, { beforeEach } from "node:test"; +import assert from "node:assert/strict"; + +import { + setAuthorModerationOverride, + getAuthorModerationOverride, + hasAuthorModerationOverride, + clearAuthorModerationOverride, + getAuthorModerationOverridesList, + loadAuthorModerationOverridesFromStorage, +} from "../js/state/cache.js"; + +const HEX = "a".repeat(64); +const HEX2 = "b".repeat(64); + +beforeEach(() => { + localStorage.clear(); + clearAuthorModerationOverride(HEX); + clearAuthorModerationOverride(HEX2); +}); + +test("set + get + has recognizes an account-level override", () => { + const entry = setAuthorModerationOverride(HEX, { showAnyway: true }); + assert.equal(entry.authorPubkey, HEX); + assert.equal(entry.showAnyway, true); + assert.equal(hasAuthorModerationOverride(HEX), true); + assert.equal(getAuthorModerationOverride(HEX)?.showAnyway, true); + assert.equal(hasAuthorModerationOverride(HEX2), false); +}); + +test("hex input is normalized (uppercase accepted, stored lowercase)", () => { + setAuthorModerationOverride(HEX.toUpperCase(), { showAnyway: true }); + assert.equal(hasAuthorModerationOverride(HEX), true, "uppercase resolves to the same entry"); +}); + +test("clearing removes it; showAnyway:false is treated as a removal", () => { + setAuthorModerationOverride(HEX, { showAnyway: true }); + assert.equal(clearAuthorModerationOverride(HEX), true); + assert.equal(hasAuthorModerationOverride(HEX), false); + + setAuthorModerationOverride(HEX, { showAnyway: true }); + assert.equal(setAuthorModerationOverride(HEX, { showAnyway: false }), null); + assert.equal(hasAuthorModerationOverride(HEX), false); +}); + +test("survives a reload via persist → load roundtrip", () => { + setAuthorModerationOverride(HEX, { showAnyway: true }); + setAuthorModerationOverride(HEX2, { showAnyway: true }); + // A reload repopulates the in-memory map from localStorage. + loadAuthorModerationOverridesFromStorage(); + const list = getAuthorModerationOverridesList() + .map((entry) => entry.authorPubkey) + .sort(); + assert.deepEqual(list, [HEX, HEX2].sort()); +}); + +test("blank/invalid pubkey is ignored", () => { + assert.equal(setAuthorModerationOverride("", { showAnyway: true }), null); + assert.equal(setAuthorModerationOverride("not-a-key", { showAnyway: true }), null); + assert.equal(getAuthorModerationOverridesList().length, 0); +}); From 5844a36a5458169ca6972350e255562a468abd63 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:01:49 -0400 Subject: [PATCH 02/14] Add "Always show creator" overlay button for account-level WoT override Adds a second entry point (alongside the Safety-tab npub input) for the per-author moderation override. On blurred/warned video surfaces, an "Always show creator" button now sits beside "Show anyway". Clicking it dispatches a `video:trust-author` document CustomEvent; the single app-level listener in applicationBootstrap.js applies the account-level override and refreshes affected videos, clearing the web-of-trust warning for every video from that creator until reset. Wired into VideoCard, VideoModal (persistent reused button + teardown cleanup), and SimilarContentCard. Co-Authored-By: Claude Opus 4.8 --- js/ui/applicationBootstrap.js | 18 ++++++ js/ui/components/SimilarContentCard.js | 40 ++++++++++++ js/ui/components/VideoCard.js | 41 ++++++++++++ js/ui/components/VideoModal.js | 89 ++++++++++++++++++++++++++ 4 files changed, 188 insertions(+) diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index 4d5fd701..ebd96633 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -1122,6 +1122,24 @@ export default class ApplicationBootstrap { ), ); } + // #24: "Always show this creator" overlay button → set the account-level + // web-of-trust override for that author + refresh the feed. Fired by the + // moderation badge on video cards / the player / similar cards. + if (typeof document !== "undefined") { + const onTrustAuthor = (event) => { + const pubkey = + typeof event?.detail?.pubkey === "string" ? event.detail.pubkey : ""; + if (!pubkey) { + return; + } + setAuthorModerationOverride(pubkey, { showAnyway: true }); + notifyAuthorModerationOverrideChanged(app, pubkey); + }; + document.addEventListener("video:trust-author", onTrustAuthor); + nostrUnsubscribes.push(() => + document.removeEventListener("video:trust-author", onTrustAuthor), + ); + } const submissionsPollId = setInterval(() => { void app.refreshSubmissionsIndicator({ reason: "poll" }); }, 180000); diff --git a/js/ui/components/SimilarContentCard.js b/js/ui/components/SimilarContentCard.js index 93705b69..8f35a33f 100644 --- a/js/ui/components/SimilarContentCard.js +++ b/js/ui/components/SimilarContentCard.js @@ -134,6 +134,8 @@ export class SimilarContentCard { this.handleModerationHideClick(event); this.boundModerationBlockHandler = (event) => this.handleModerationBlockClick(event); + this.boundTrustAuthorHandler = (event) => + this.handleTrustAuthorClick(event); this.build(); } @@ -524,6 +526,43 @@ export class SimilarContentCard { return button; } + createModerationTrustAuthorButton() { + const button = this.document.createElement("button"); + button.classList.add( + "moderation-badge__action", + "flex-shrink-0", + "text-xs", + "py-1", + "px-2" + ); + button.type = "button"; + button.dataset.moderationAction = "trust-author"; + button.setAttribute("aria-label", "Always show videos from this creator"); + button.textContent = "Always show creator"; + button.addEventListener("click", this.boundTrustAuthorHandler); + button.classList.add("pointer-events-auto"); + return button; + } + + handleTrustAuthorClick(event) { + event?.preventDefault?.(); + event?.stopPropagation?.(); + + const pubkey = + this.video?.pubkey || this.video?.author?.pubkey || ""; + if (!pubkey || typeof document === "undefined") { + return; + } + + try { + document.dispatchEvent( + new CustomEvent("video:trust-author", { detail: { pubkey } }) + ); + } catch (error) { + // Non-fatal: trust-author is a convenience action. + } + } + handleShowAnywayClick(event) { event?.preventDefault?.(); event?.stopPropagation?.(); @@ -720,6 +759,7 @@ export class SimilarContentCard { actions.appendChild(showButton); this.moderationActionButton = showButton; this.moderationActionButtonMode = "override"; + actions.appendChild(this.createModerationTrustAuthorButton()); } } diff --git a/js/ui/components/VideoCard.js b/js/ui/components/VideoCard.js index 39016da4..da66cdaa 100644 --- a/js/ui/components/VideoCard.js +++ b/js/ui/components/VideoCard.js @@ -156,6 +156,8 @@ export class VideoCard { this.moderationBadgeSlot = null; this.hiddenSummaryEl = null; this.boundShowAnywayHandler = (event) => this.handleShowAnywayClick(event); + this.boundTrustAuthorHandler = (event) => + this.handleTrustAuthorClick(event); this.boundModerationBlockHandler = (event) => this.handleModerationBlockClick(event); this.boundModerationHideHandler = (event) => @@ -1210,6 +1212,44 @@ export class VideoCard { return button; } + // Account-level ("trusted creator") action shown alongside "Show anyway": + // turns off the web-of-trust warning for EVERY video by this creator. Fires a + // document event that the app handles (sets the per-author override + refresh) + // — no per-renderer callback plumbing needed. + createModerationTrustAuthorButton() { + const button = this.createElement("button", { + classNames: ["moderation-badge__action", "flex-shrink-0"], + attrs: { + type: "button", + "data-moderation-action": "trust-author", + "aria-describedby": this.getModerationBadgeId(), + "aria-label": "Always show videos from this creator", + }, + textContent: "Always show creator", + }); + button.addEventListener("click", this.boundTrustAuthorHandler); + return button; + } + + handleTrustAuthorClick(event) { + if (event) { + event.preventDefault?.(); + event.stopPropagation?.(); + } + const pubkey = + this.video?.pubkey || this.video?.author?.pubkey || ""; + if (!pubkey || typeof document === "undefined") { + return; + } + try { + document.dispatchEvent( + new CustomEvent("video:trust-author", { detail: { pubkey } }), + ); + } catch (error) { + userLogger.warn("[VideoCard] trust-author dispatch failed", error); + } + } + createModerationHideButton() { const { text: label, ariaLabel } = getModerationOverrideActionLabels({ overrideActive: true, @@ -1638,6 +1678,7 @@ export class VideoCard { actions.appendChild(showButton); this.moderationActionButton = showButton; this.moderationActionButtonMode = "override"; + actions.appendChild(this.createModerationTrustAuthorButton()); } hasActions = true; } else { diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js index e2a2d772..3d6789d5 100644 --- a/js/ui/components/VideoModal.js +++ b/js/ui/components/VideoModal.js @@ -251,6 +251,7 @@ export class VideoModal { this.moderationBadgeText = null; this.moderationActionsContainer = null; this.moderationPrimaryButton = null; + this.moderationTrustAuthorButton = null; this.moderationBlockButton = null; this.moderationPrimaryMode = ""; this.moderationBadgeId = ""; @@ -268,6 +269,8 @@ export class VideoModal { this.handleModerationHideClick.bind(this); this.handleModerationBlockClick = this.handleModerationBlockClick.bind(this); + this.handleModerationTrustAuthorClick = + this.handleModerationTrustAuthorClick.bind(this); this.handleGlobalModerationOverride = this.handleGlobalModerationOverride.bind(this); this.handleGlobalModerationBlock = @@ -556,6 +559,12 @@ export class VideoModal { ); } } + if (this.moderationTrustAuthorButton) { + this.moderationTrustAuthorButton.removeEventListener( + "click", + this.handleModerationTrustAuthorClick, + ); + } if (this.moderationBlockButton) { this.moderationBlockButton.removeEventListener( "click", @@ -568,6 +577,7 @@ export class VideoModal { this.moderationActionsContainer = null; this.moderationPrimaryButton = null; this.moderationPrimaryMode = ""; + this.moderationTrustAuthorButton = null; this.moderationBlockButton = null; const previousScrollRegion = this.scrollRegion; @@ -2009,8 +2019,28 @@ export class VideoModal { } actionsAttached = true; } + + if (mode === "override") { + const trustButton = this.ensureModerationTrustAuthorButton(); + if (trustButton) { + trustButton.disabled = false; + trustButton.removeAttribute("aria-busy"); + if (badgeId) { + trustButton.setAttribute("aria-describedby", badgeId); + } else { + trustButton.removeAttribute("aria-describedby"); + } + if (actions && trustButton.parentElement !== actions) { + actions.appendChild(trustButton); + } + actionsAttached = true; + } + } else { + this.removeModerationTrustAuthorButton(); + } } else { this.removeModerationPrimaryButton(); + this.removeModerationTrustAuthorButton(); } if (this.shouldShowModerationBlockAction(context)) { @@ -2160,6 +2190,65 @@ export class VideoModal { this.moderationPrimaryMode = ""; } + ensureModerationTrustAuthorButton() { + if (!this.document) { + return null; + } + + if (!this.moderationTrustAuthorButton) { + const button = this.document.createElement("button"); + button.type = "button"; + button.className = "moderation-badge__action flex-shrink-0"; + button.dataset.moderationAction = "trust-author"; + button.textContent = "Always show creator"; + button.setAttribute( + "aria-label", + "Always show videos from this creator" + ); + button.addEventListener("click", this.handleModerationTrustAuthorClick); + this.moderationTrustAuthorButton = button; + } + + return this.moderationTrustAuthorButton; + } + + removeModerationTrustAuthorButton() { + const button = this.moderationTrustAuthorButton; + if (!button) { + return; + } + + button.removeEventListener("click", this.handleModerationTrustAuthorClick); + if (button.parentElement) { + button.parentElement.removeChild(button); + } + + this.moderationTrustAuthorButton = null; + } + + handleModerationTrustAuthorClick(event) { + if (event) { + event.preventDefault?.(); + event.stopPropagation?.(); + } + + const pubkey = + this.activeVideo?.pubkey || + this.activeVideo?.author?.pubkey || + ""; + if (!pubkey || typeof document === "undefined") { + return; + } + + try { + document.dispatchEvent( + new CustomEvent("video:trust-author", { detail: { pubkey } }) + ); + } catch (error) { + devLogger?.warn?.("[VideoModal] trust-author dispatch failed", error); + } + } + ensureModerationBlockButton() { if (!this.document) { return null; From c5bdd83183bffcd726db3f7b31022c40117c295a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:05:35 -0400 Subject: [PATCH 03/14] test(#34): make my-videos mirror relay-truth test self-contained MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mirror flag helpers (nip71MirrorFlags) read the global `localStorage`, but this test file only wired `globalThis.document` from JSDOM — never `localStorage`. It therefore passed only when run inside the full suite, relying on another test file leaking the localStorage shim into the shared global. Run in isolation it failed 4/4 (isMirrorEnabled always returned false → "Share to apps" instead of "Shared ✓"). Import the existing setup-localstorage shim so the file is honest in isolation. No assertions changed — the product code (#34 relay-truth detection) was already correct; this only removes a load-order dependency. Co-Authored-By: Claude Opus 4.8 --- tests/my-videos-mirror-relay-truth.test.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/my-videos-mirror-relay-truth.test.mjs b/tests/my-videos-mirror-relay-truth.test.mjs index 51dc3fee..0e9f34a9 100644 --- a/tests/my-videos-mirror-relay-truth.test.mjs +++ b/tests/my-videos-mirror-relay-truth.test.mjs @@ -9,6 +9,11 @@ // - refreshMirrorStates() looks up relay truth (batched), corrects the button // label, AND reconciles the local flag so it becomes a correct cache. +// Self-contained localStorage shim: the mirror flag helpers read the global +// `localStorage`, so without this the suite only passed by relying on another +// test file leaking the shim into the shared global (brittle load-order +// dependency). Importing it here makes this file honest in isolation. +import "./test-helpers/setup-localstorage.mjs"; import test, { beforeEach, afterEach } from "node:test"; import { strict as assert } from "node:assert"; import { JSDOM } from "jsdom"; From 311d44c8a78c1f4a72eda19be87107ed691f23ec Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:22:32 -0400 Subject: [PATCH 04/14] =?UTF-8?q?docs(todo):=20de-stale=20pre-launch=20lis?= =?UTF-8?q?t=20=E2=80=94=20mark=20completed=20items,=20record=20#24=20core?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audited the pre-launch TODO against the codebase. Several items were fully implemented (with DONE/FIXED notes in their bodies and code verified present) but their section headers never got a completion marker, making the list look larger than it is. Updated headers for: #4 view-counter, #20 feed reliability, #41 native-dialogs, #45 tag suggestions, #46 post-publish refresh, #48 fluent profile switching, #25 per-event block list, #52 edit-refresh, #53 channel link, #54 like/ dislike buttons, #34 NIP-71 mirror (dev done; live Amber VERIFY remains). Recorded #24 (WoT per-author allow-override): core render-time override + both UI entry points landed this session (a0131c71, 5844a36a); narrowed the remaining open scope to the deferred NIP-51 sync + import follow-up. No code changes — documentation only. Co-Authored-By: Claude Opus 4.8 --- todo/TODO_2026-06-20_pre-launch.md | 50 ++++++++++++++++++------------ 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 67080b04..9181009a 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -279,7 +279,7 @@ Audit started 2026-06-23. See the doc for architecture map + findings. Summary: - [x] Comment box confirmed WORKING (the "message doesn't work" was the popover mis-position making it hard to use — see 3a). -### 4. View counter accuracy & reliability — DEEP AUDIT +### 4. View counter accuracy & reliability — DEEP AUDIT — DONE 2026-06-24 Audit finding (2026-06-24): counts INFLATE. `viewCounter.js` dedupes locally by (viewer, time-window) correctly, but two things defeated it: (a) `generateViewEventDedupeTag` built the kind-30079 `d` tag with random entropy + @@ -421,7 +421,7 @@ un-hides when WebTorrent flips green). The real gaps: brand-new person, confirm the thread opens on THEM and the sent message lands in that conversation after refresh. -### 20. Feed / grid loading reliability — some videos missing (BUG) +### 20. Feed / grid loading reliability — some videos missing (BUG) — FIXED (streaming/first-relay-wins render is a separate latency follow-up under #17d) Two related intermittent symptoms; likely the same root cause (a one-shot, all-relays-settle fetch that drops slow-relay results — see 17d). - [x] **Profile / Channel page sometimes doesn't pull in all of a creator's @@ -504,7 +504,7 @@ toward freshness and looked identical. Gave each a structural identity: - [ ] Consider removing the CDN/WebTorrent source badge from the card (clutter). - [ ] Run `npm run test:visual` after layout changes; update baselines deliberately. -### 41. Native browser dialogs → site notification system +### 41. Native browser dialogs → site notification system — DONE 2026-06-30 - [x] **DONE 2026-06-30.** Replaced every `alert()`/`confirm()` (no `prompt()` existed) with the app's notification system so dialogs are styled + consistent. - `alert()` (13) → toasts: `this.showError` where the component already had it @@ -584,7 +584,7 @@ toward freshness and looked identical. Gave each a structural identity: Storage Status → switch destination → summary updates and the upload lands in the chosen bucket; with 1 connection the picker stays hidden. -### 45. Upload modal: suggest tags from the user's previous videos (UX) +### 45. Upload modal: suggest tags from the user's previous videos (UX) — DONE 2026-07-01 - [x] **DONE 2026-07-01.** The Hashtags section of the upload modal now shows one-tap chips of the user's most-used past hashtags ("Reuse a tag from your videos:"). Source: `app.getUserHashtagSuggestions()` reads the user's own videos @@ -597,7 +597,7 @@ toward freshness and looked identical. Gave each a structural identity: for users with no past tags. Rendered on modal open. Tests: `tests/hashtag-suggestions.test.mjs` (5, pure ranking). Lint + build green. -### 46. Feed auto-refreshes after posting a video (UX) +### 46. Feed auto-refreshes after posting a video (UX) — DONE 2026-06-30 - [x] **DONE 2026-06-30.** `publishVideoNote` now (1) **optimistically injects** the just-published legacy event into the shared client cache via a new `nostrClient.ingestLocalVideoEvent(rawEvent)` (+ thin `nostrService` wrapper), so the @@ -664,7 +664,7 @@ toward freshness and looked identical. Gave each a structural identity: video → within ~2min of tab re-entry its rank + badge reflect the new total. -### 48. Fluent profile switching across signing methods (nsec / NIP-46 / NIP-07) +### 48. Fluent profile switching across signing methods (nsec / NIP-46 / NIP-07) — DONE 2026-06-30 - [x] **nsec switching FIXED 2026-06-30.** Switching to a saved nsec profile used to fail silently ("secret-required") — `switchProfile` called the nsec provider with no passphrase and never prompted. Now `handleProfileSwitchRequest` detects an nsec @@ -807,15 +807,27 @@ toward freshness and looked identical. Gave each a structural identity: - [ ] Define the submission schema + the admin action → list-mutation mapping; reuse the existing admin list stores (whitelist/blacklist) for the writes. -### 24. User-level allowlist override for the Web-of-Trust mute list -- [ ] Let a user keep (and import) a personal **allowlist** that the client ALWAYS - shows content from, overriding the inherited WoT/trusted-mute list. So even if a - trusted curator mutes an author, the user can force that author's content to - appear in their own feeds. Persist it (NIP-51 list, encrypted/opt-in), make it - importable, and apply it at the render-time moderation filter as a final - allow-override after the WoT mute/block checks. +### 24. User-level allowlist override for the Web-of-Trust mute list — CORE DONE 2026-07-08 +- [x] **Core render-time allow-override — DONE 2026-07-08.** A per-author (per-npub) + override that ALWAYS shows a creator's content, overriding the inherited + WoT/trusted-mute warning (blur/hide) for every one of that author's videos until + reset. Stored in a dedicated cache (`bitvid:moderationAuthorOverrides:v1`, + `js/state/cache.js`) and applied as the final allow-override at the single + render-time integration point (`js/services/moderationDecorator.js` — sets + `overrideActive` when `getAuthorModerationOverride(pubkey).showAnyway`). Two entry + points: (a) the **Safety tab** "Trusted creators" npub input + list with per-row + Reset (`ProfileModerationController`), and (b) an **"Always show creator"** button + on every blurred/warned surface (VideoCard, VideoModal, SimilarContentCard) that + dispatches a `video:trust-author` event handled once in `applicationBootstrap.js`. + Tests: `tests/author-moderation-override.test.mjs` (5). Committed a0131c71 (Safety + tab) + 5844a36a (overlay button). +- [ ] **Remaining (deferred, not blocking launch): NIP-51 sync + import.** The override + is currently **device-local** (localStorage), not a published NIP-51 list, so it + doesn't roam across devices and can't be imported/shared. Follow-up: persist as an + encrypted/opt-in NIP-51 list and make it importable, reusing the cross-login sync + machinery (#15). -### 25. Per-video / per-event admin block list (granular moderation) +### 25. Per-video / per-event admin block list (granular moderation) — DONE 2026-07-01 - [x] **DONE 2026-06-30 (full vertical slice).** Admins (any editor — same gate as the author blacklist) can now hide a SINGLE video without blocking its author. - **Record:** a published kind-30000 NIP-51 list under d-tag @@ -996,7 +1008,7 @@ to rescue a creator who *does* get WoT-hidden, so if it happens, ship this promp a signed Nostr event per Blossom rather than S3 keys. Research the BUD spec coverage needed for bitvid's upload/delete flows. -### 34. NIP-71 mirror: videos show "not mirrored" + duplicate on re-mirror (BUG) +### 34. NIP-71 mirror: videos show "not mirrored" + duplicate on re-mirror (BUG) — FIXED 2026-06-29 (only live Amber VERIFY remains) Reported 2026-06-25. Relates to #17 (NIP-71 interop) / the bitvid→NIP-71 mirror. - [ ] **Mirrored videos report as NOT mirrored.** All videos were mirrored, but the UI now shows them un-mirrored even though the NIP-71 mirror events exist. The @@ -1772,7 +1784,7 @@ passphrase-encrypted). Items 51, 56, 57 are all facets of that. remembered (disk vs session tier) in the UI, and consider a "keep unlocked" toggle in the storage/wallet panes for users who set it up there rather than via a prompt. -### 52. After editing a video, the video grids don't refresh to show the update (BUG) +### 52. After editing a video, the video grids don't refresh to show the update (BUG) — FIXED 2026-07-02 - [x] **FIXED 2026-07-02.** The edit flow already re-ran `loadVideos()`, but the cache still held the PRE-edit version: unlike new publishes (#46), the edit never ingested its signed replaceable event locally, so grids re-rendered stale until @@ -1781,7 +1793,7 @@ passphrase-encrypted). Items 51, 56, 57 are all facets of that. store + `videos:updated`), so grids show the new title/thumbnail/etc. immediately. Test: `tests/services/nostr-service.test.mjs` (+1). -### 53. Channel-profile link in the video player modal doesn't work (BUG) +### 53. Channel-profile link in the video player modal doesn't work (BUG) — FIXED 2026-07-02 - [x] **FIXED 2026-07-02.** Event-name mismatch: `VideoModal.handleCreatorNavigation` dispatched `"navigate:profile"`, but ModalManager only listens for `"creator:navigate"` → the click went into the void (same class as the old @@ -1792,8 +1804,8 @@ passphrase-encrypted). Items 51, 56, 57 are all facets of that. `tests/video-modal-controllers.test.mjs` (+2 — dispatches creator:navigate with the pubkey, never the dead event; no-op without a pubkey). Lint + build green. -### 54. Like / dislike buttons in the video player modal don't work (BUG) -- [ ] The reaction (like/dislike) buttons in the player modal are unresponsive / don't +### 54. Like / dislike buttons in the video player modal don't work (BUG) — FIXED 2026-07-02 (only live VERIFY remains) +- [x] The reaction (like/dislike) buttons in the player modal are unresponsive / don't persist. Audit `reactionController` wiring into the video modal (event binding, active-signer availability, publish path). Note: reactions require signing — verify this isn't another "lost signer after refresh" (#57) surfacing as a dead button; if From df06d84e93b07d7c2851316c798f7b29a27cb313 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 9 Jul 2026 08:46:26 -0400 Subject: [PATCH 05/14] =?UTF-8?q?docs(todo):=20add=20Design-Sync=20trackin?= =?UTF-8?q?g=20list=20(bitvid=20=E2=86=92=20Claude=20Design=20port)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the incremental port of bitvid's token + component system into the Claude Design project (projectId 6b3b1e2e-…): what it is, the DesignSync workflow + card conventions (pixel-faithful, resolve @apply, token-linked, both themes), Pass 1 done (5 Foundations + 2 Components cards synced), and the queued expansion groups (menus/overlays, badges/pills, buttons/forms, modals, larger surfaces) plus foundational gaps (red override accent, blur/breakpoint tokens). Co-Authored-By: Claude Opus 4.8 --- todo/TODO_2026-07-09_design-sync.md | 86 +++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 todo/TODO_2026-07-09_design-sync.md diff --git a/todo/TODO_2026-07-09_design-sync.md b/todo/TODO_2026-07-09_design-sync.md new file mode 100644 index 00000000..b37a1158 --- /dev/null +++ b/todo/TODO_2026-07-09_design-sync.md @@ -0,0 +1,86 @@ +# TODO — Design-Sync: port bitvid's design system → Claude Design + +Started 2026-07-09. Tracks the incremental port of bitvid's token + component +system into the **Claude Design** project so it becomes a shared, faithful, +versioned source-of-truth (readable by Claude when building bitvid UI and, later, +other apps in the same visual language). + +## What this is (and isn't) + +- **Is:** a curated, pixel-faithful reproduction of bitvid's real design system as + a gallery of self-contained preview cards in a Claude Design project. Each card + is rebuilt from the actual `css/tokens.css` values + `tailwind.source.css` + component rules + component JS markup. +- **Is not:** a one-shot automated export, and not a runtime package other apps + `import`. The project is a *reference library*; the repo stays the code source. +- Sync is **incremental — one component at a time, never a wholesale replace** + (the DesignSync tool enforces this). + +## Project + tooling + +- Project: **"bitvid Design System"** — `type: PROJECT_TYPE_DESIGN_SYSTEM` + - `projectId`: `6b3b1e2e-8d55-4b95-873a-52b6d886c186` +- Tool: `DesignSync` (claude.ai/design). One-time auth per session via `/design-login`. +- **The project itself is the durable store.** Card sources this pass were staged + in the session scratchpad (ephemeral) — to edit a card later, `get_file` it back, + edit, then `finalize_plan` → `write_files`. Don't rely on scratchpad persisting. + +## Card conventions (follow for every new card) + +- First line MUST be `` + (the pane compiles the card index from these markers on its self-check). +- **Self-contained + pixel-faithful:** resolve every Tailwind `@apply` to concrete + CSS; drive all values from the real tokens (`--space-*`, `--radius-*`, `--status-*`, + the exact admin ring/star box-shadow, `--color-zap`, …). No approximations. +- Link the token files (`../tokens/*.css`) + `../styles.css` so `var()` resolves and + both themes work (tokens carry `[data-theme="light"|"dark"]` scopes). +- External images are blocked in the render sandbox → gradient placeholders for + thumbnails; reuse in-project assets (`../assets/svg/default-profile.svg`) for avatars. + +## Done + +### Seeded by the UI generator (pre-existing, verified accurate) +- [x] Token foundation `tokens/{colors,semantic,typography,spacing,effects,fonts}.css` + — a faithful ~155-token curated subset of the real 670-prop `tokens.css`; + values match production exactly (accent indigo `rgb(99 102 241)`, dark surfaces, + `--color-zap` btc-orange, spacing/radii/type all verified). Both themes. +- [x] **Colors** group (5 cards): accent, status, surfaces-dark, surfaces-light, text. +- [x] Real bitvid assets/logos imported (`assets/`, `bitvid_logo/`). + +### Pass 1 — foundation backfill + flagship components (synced 2026-07-09) +- [x] **Foundations** group (5 cards): `typography` (Inter/Fira Code + full type scale), + `spacing` (4xs→2xl-plus ramp), `radii` (incl. sidebar/modal shells), `shadows` + → "Elevation" (sm→modal + zap popover), `motion` (live easing curves). +- [x] **Components** group (2 cards): + - `video-card` — default (admin creator: gold ring + corner star), Private + (`data-state="private"`), Moderation-blurred (warning `moderation-badge` + overlay w/ "Show anyway" / "Always show creator"). + - `nostr-profile` — compact account card (`createCompactProfileSummary` + + `.profile-account-card`): subscription, admin-override (ring+star), blocked. + +## Queued (expansion, roughly value-ordered) + +- [ ] **Group A — overlays/menus:** the `⋯` more-menu / dropdown, notification toast + (`appNotify` / NotificationController), confirm + prompt dialogs + (`confirmDialog.js` / `promptDialog.js` / `showPasswordPrompt`). +- [ ] **Group B — badges & pills:** `moderation-badge` neutral variant, standalone + admin star, sats/zap badge, acting-as pill, `.pill`. +- [ ] **Group C — buttons & form controls:** `.form-control`, `.btn-ghost` + (accent/danger), primary button, focus-ring treatment. +- [ ] **Group D — modal shell** → then the big modals: upload, profile, login, + share-nostr, embed-video. +- [ ] **Group E — larger surfaces:** sidebar/nav (`sidebar.html`), account switcher, + video player modal, DM surfaces, search/filter modal. + +## Foundational gaps to capture later + +- [ ] **Red "override" accent theme** — `--color-accent-override: rgb(254 0 50)` + + strong/pressed. bitvid ships an alternate red accent; not in the curated set. +- [ ] Blur tokens (`--blur-*`), breakpoints (`--breakpoint-*`), avatar sizes, + component-sizing tokens — reference cards if/when a component needs them. + +## Verify (user) + +- [ ] Open claude.ai/design → bitvid Design System; confirm the 7 new cards render + in both themes and the admin ring/star + zap-orange read correctly. If any + card is missing from the index, it needs explicit `register_assets`. From 1a4e53bdb151a4a83c475d0a8d7503ee1fd35636 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:04:37 -0400 Subject: [PATCH 06/14] =?UTF-8?q?docs(todo):=20add=20#60=20=E2=80=94=20Aud?= =?UTF-8?q?io=20tab=20(podcasts/music)=20+=20near-term=20audio-leak=20guar?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the future Audio sidebar tab (dedicated