mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Remove local blog view in favor of configurable external link
This commit is contained in:
1 parent
3993b4c824
commit
8c568af46a
20 files changed
+210
-4797
No files matched your search
@@ -126,7 +126,7 @@ hairline borders or browser reset quirks) add an explicit allowlist entry in
|
||||
`scripts/check-design-tokens.mjs` so future contributors understand why the
|
||||
constant exists.
|
||||
|
||||
The script now crawls every markup-emitting module under `js/blog`, `js/ui`,
|
||||
The script now crawls every markup-emitting module under `js/ui`,
|
||||
`js/channelProfile.js`, and `torrent/ui`, so UI tweaks anywhere in the repo
|
||||
benefit from the guard without manual configuration.
|
||||
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="blog-html">
|
||||
<head>
|
||||
<!-- Configure with your npub/nprofile -->
|
||||
<meta
|
||||
name="author"
|
||||
value="npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe"
|
||||
/>
|
||||
|
||||
<!-- Personalize your relays list -->
|
||||
<meta
|
||||
name="relays"
|
||||
value="wss://nos.lol, wss://relay.damus.io, wss://relay.nostr.band, wss://nostr.wine"
|
||||
/>
|
||||
|
||||
<!-- Show top notes with images, use a even value, zero to disable -->
|
||||
<meta name="top-notes" value="2" />
|
||||
|
||||
<!-- Show short notes as carousel (value 'carousel') or in the main feed (value 'main'), empty to disable -->
|
||||
<meta name="short-notes" value="carousel" />
|
||||
|
||||
<!-- Show short notes with X minimum characters for the 'short-notes' setting, zero to disable -->
|
||||
<meta name="short-notes-min-chars" value="800" />
|
||||
|
||||
<!-- Size in characters of the short notes preview in the feed, zero to display full content -->
|
||||
<meta name="short-notes-summary-max-chars" value="400" />
|
||||
|
||||
<!-- Enable navigation by topics (tags), empty to disable -->
|
||||
<meta name="topics" value="" />
|
||||
|
||||
<!-- Enable comments, 'yes' to enable -->
|
||||
<meta name="comments" value="yes" />
|
||||
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Explore bitvid release notes, roadmap updates, and community highlights across the decentralized video network."
|
||||
/>
|
||||
<meta name="robots" content="index,follow" />
|
||||
<title>bitvid Blog</title>
|
||||
<link rel="stylesheet" href="./css/tailwind.generated.css" />
|
||||
<script type="module" crossorigin>
|
||||
import { bootstrapBlog } from "./js/blog/index.js";
|
||||
|
||||
const startBlog = () => {
|
||||
bootstrapBlog();
|
||||
};
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", startBlog, { once: true });
|
||||
} else {
|
||||
startBlog();
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="blog-body" data-ds="new">
|
||||
<div
|
||||
id="app"
|
||||
class="blog-app-root mx-auto my-8 w-full max-w-none px-4 md:w-blog-fluid md:max-w-blog-shell md:px-0"
|
||||
data-ds="new"
|
||||
></div>
|
||||
<script>
|
||||
window.wnjParams = {
|
||||
position: "bottom",
|
||||
accent: "neutral",
|
||||
startHidden: true,
|
||||
compactMode: true,
|
||||
};
|
||||
</script>
|
||||
<div id="uiOverlay" data-overlay-root aria-hidden="true"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -168,7 +168,13 @@
|
||||
/>
|
||||
<span class="sidebar-text">Roadmap</span>
|
||||
</a>
|
||||
<a href="#view=blog" class="sidebar-nav-link sidebar-nav-link--sub" title="Blog">
|
||||
<a
|
||||
href="#"
|
||||
data-blog-link
|
||||
class="sidebar-nav-link sidebar-nav-link--sub"
|
||||
title="Blog"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/blog-icon.svg"
|
||||
alt=""
|
||||
|
||||
@@ -44,6 +44,16 @@ export const ADMIN_SUPER_NPUB =
|
||||
*/
|
||||
export const BITVID_WEBSITE_URL = "https://bitvid.network/";
|
||||
|
||||
/**
|
||||
* External destination for the blog menu link.
|
||||
*
|
||||
* Sidebar navigation routes visitors to this URL when they select "Blog".
|
||||
* Update the value to point at the long-form publishing platform you
|
||||
* maintain for release notes or community updates.
|
||||
*/
|
||||
export const BLOG_URL =
|
||||
"https://habla.news/p/npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe";
|
||||
|
||||
/**
|
||||
* Default image included in automated moderation DMs.
|
||||
*
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
PLATFORM_FEE_PERCENT,
|
||||
PLATFORM_LUD16_OVERRIDE,
|
||||
THEME_ACCENT_OVERRIDES,
|
||||
BLOG_URL,
|
||||
} from "./instance-config.js";
|
||||
|
||||
function assertSuperAdminConfigured(value) {
|
||||
@@ -111,9 +112,35 @@ function resolveConfig(overrides = {}) {
|
||||
overrides.PLATFORM_LUD16_OVERRIDE ?? PLATFORM_LUD16_OVERRIDE,
|
||||
themeAccentOverrides:
|
||||
overrides.THEME_ACCENT_OVERRIDES ?? THEME_ACCENT_OVERRIDES,
|
||||
blogUrl: overrides.BLOG_URL ?? BLOG_URL,
|
||||
};
|
||||
}
|
||||
|
||||
function assertBlogUrl(url) {
|
||||
if (typeof url !== "string") {
|
||||
throw new Error("BLOG_URL must be a string.");
|
||||
}
|
||||
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("BLOG_URL cannot be empty. Provide a fully qualified URL.");
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (!parsed.protocol || !/^https?:$/.test(parsed.protocol)) {
|
||||
throw new Error("BLOG_URL must use http or https.");
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
"BLOG_URL must be a valid http(s) URL defined in config/instance-config.js.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function validateInstanceConfig(overrides) {
|
||||
const config = resolveConfig(overrides);
|
||||
assertSuperAdminConfigured(config.adminSuperNpub);
|
||||
@@ -131,4 +158,5 @@ export function validateInstanceConfig(overrides) {
|
||||
}
|
||||
|
||||
assertThemeAccentOverrides(config.themeAccentOverrides);
|
||||
assertBlogUrl(config.blogUrl);
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
:root{--surface-base:var(--color-surface);--surface-alt:var(--color-surface-alt);--surface-raised:var(--color-card);--surface-raised-private:var(--color-card-private);--surface-raised-critical:var(--color-card-critical);--surface-panel:var(--color-panel);--surface-panel-hover:var(--color-panel-hover);--surface-sunken:var(--color-surface-sunken);--surface-muted:var(--color-surface-muted);--surface-veil:var(--color-overlay-veil);--surface-inverse:var(--color-page);--surface-overlay:var(--color-overlay);--surface-overlay-muted:var(--color-overlay-muted);--surface-overlay-strong:var(--color-overlay-strong);--surface-overlay-darker:var(--color-overlay-darker);--surface-overlay-black:var(--color-overlay-black);--surface-overlay-banner:var(--surface-overlay-banner-bottom);--surface-overlay-banner-bottom:var(--color-overlay-banner-bottom);--surface-overlay-banner-transparent:var(--color-overlay-banner-transparent);--surface-overlay-banner-gradient:linear-gradient(to top,var(--surface-overlay-banner-bottom) 0%,var(--surface-overlay-banner-transparent) 60%,var(--surface-overlay-banner-transparent) 100%);--surface-overlay-panel:var(--color-overlay-panel);--surface-overlay-panel-soft:var(--color-overlay-panel-soft);--surface-overlay-panel-tint:var(--color-overlay-panel-tint);--surface-overlay-panel-glass:var(--color-overlay-panel-glass);--surface-overlay-panel-glass-soft:var(--color-overlay-panel-glass-soft);--surface-modal-backdrop:var(--color-modal-backdrop);--surface-modal:var(--color-modal-surface);--blur-modal-backdrop:18px;--blur-modal-alert:14px;--blur-thumbnail-blur:18px;--modal-nav-backdrop-blur:6px;--border-default:var(--color-border);--border-subtle:var(--color-border-subtle);--border-strong:var(--color-border-translucent-stronger);--border-overlay:var(--color-border-translucent);--border-overlay-strong:var(--color-border-translucent-strongest);--border-overlay-glow:var(--color-border-translucent-glow);--border-neutral-soft:var(--color-border-neutral-soft);--border-info-soft:var(--color-border-info-soft);--border-sidebar-strong:var(--color-border-sidebar-strong);--border-width-hairline:1px;--border-width-fine:0.5px;--text-primary:var(--color-text);--text-inverse:var(--color-text-inverse);--text-muted:var(--color-text-muted);--text-muted-strong:var(--color-muted-strong);--text-subtle:var(--color-subtle-text);--text-frosted-primary:var(--color-text-frosted);--text-frosted-strong:var(--color-text-frosted-strong);--text-frosted-soft:var(--color-text-frosted-soft);--status-info:var(--color-info);--status-info-strong:var(--color-info-strong);--status-info-surface:var(--color-info-glass);--status-info-border:var(--color-info-border);--status-info-on:var(--color-info-on);--status-success:var(--color-success);--status-success-strong:var(--color-success-strong);--status-success-surface:var(--color-success-surface);--status-success-border:var(--color-success-border);--status-success-on:var(--color-success-on);--status-warning:var(--color-warning);--status-warning-strong:var(--color-warning-strong);--status-warning-surface:var(--color-warning-surface);--status-warning-border:var(--color-warning-border);--status-warning-on:var(--color-warning-on);--status-danger:var(--color-critical);--status-danger-strong:var(--color-critical-strong);--status-danger-surface:var(--color-danger-surface);--status-danger-border:var(--color-danger-border);--status-danger-on:var(--color-danger-on);--status-private:var(--color-private);--status-private-strong:var(--color-private-strong);--status-private-surface:var(--color-private-surface);--status-private-border:var(--color-private-border);--status-private-on:var(--color-private-on);--status-neutral:var(--color-muted);--status-neutral-strong:var(--color-muted-strong);--status-neutral-surface:var(--color-neutral-surface);--status-neutral-border:var(--color-neutral-border);--status-neutral-on:var(--color-neutral-on);--scrollbar-width:0.5rem;--scrollbar-track:var(--color-white-soft);--scrollbar-thumb:var(--color-white-softer);--scrollbar-thumb-hover:var(--color-white-softest);--z-overlay-nav:60;--z-overlay-floating:70;--z-overlay-popover:140;--z-overlay-toast:180;--z-sidebar-overlay:10;--z-sidebar:20;--z-modal-root:100;--z-modal-overlay:110;--z-modal-content:120;--z-modal-player:130;--overlay-backdrop-color:var(--color-overlay-backdrop);--overlay-backdrop-blur:12px;--sidebar-width-expanded:16rem;--sidebar-width-collapsed:4rem;--sidebar-width:var(--sidebar-width-expanded);--sidebar-control-size:2.75rem;--sidebar-content-gap:1rem;--space-sidebar-shell-inline:var(--space-lg);--space-sidebar-shell-block-start:var(--space-lg);--space-sidebar-shell-block-end:1.75rem;--space-sidebar-scroll-gap:1.75rem;--space-sidebar-panel-gap:1.35rem;--space-sidebar-panel-padding-block:1.35rem;--space-sidebar-panel-padding-inline:1.1rem;--space-sidebar-footer-gap:0.85rem;--space-sidebar-trigger-padding-block:0.78rem;--space-sidebar-popover-offset:0.85rem;--space-sidebar-popover-padding-block:0.85rem;--space-sidebar-pLine truncated
|
||||
/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:var(--font-family-sans);font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:var(--font-family-mono);font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}html{font-size:100%}@media (min-width:1024px){html{font-size:75%}}*,:after,:before{scrollbar-width:thin;scrollbar-color:var(--scrollbar-thumb) transparent}::-webkit-scrollbar{width:var(--scrollbar-width);height:var(--scrollbar-width)}::-webkit-scrollbar-track{background:var(--scrollbar-track)}::-webkit-scrollbar-thumb{background:var(--scrollbar-thumb);border-radius:var(--radius-sm)}::-webkit-scrollbar-thumb:hover{background:var(--scrollbar-thumb-hover)}body{font-family:var(--font-family-sans);background-color:var(--color-page);color:var(--color-text-strong);line-height:1.5;margin:0;padding:0;overflow-x:hidden}#uiOverlay{position:fixed;inset:0;pointer-events:none;z-index:var(--z-overlay-popover)}#uiOverlay>*{pointer-events:auto}#editNip71TagsFieldset>legend,#nip71TagsFieldset>legend{margin-left:0;padding:0}#app{background-color:var(--color-page);color:inherit}body.modal-open,html.modal-open{overflow:hidden;height:100%}body.modal-open #app{overflow:hidden}body.modal-open #sidebar{pointer-events:none;opacity:0;visibility:hidden;z-index:var(--z-sidebar-overlay)}.ng-cloak,.ng-hide:not(.ng-hide-animate),.x-ng-cloak,[data-ng-cloak],[ng-cloak],[ng\:cloak],[x-ng-cloak]{display:none!important}ng\:form{display:block}.\!container{width:100%!important}.container{width:100%}@media (min-width:25rem){.\!container{max-width:25rem!important}.container{max-width:25rem}}@media (min-width:30rem){.\!container{max-width:30rem!important}.container{max-width:30rem}}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}#playerModal.ds-header{display:flex}.ds-header{display:flex;align-items:center;justify-content:flex-start;padding-top:var(--space-sm);padding-bottom:var(--space-sm)}.ds-metric-probe{position:absolute;top:0;left:0;visibility:hidden;pointer-events:none;contain:layout size style;inline-size:0;block-size:var(--ds-metric-probe-length,0);overflow:hidden}.ds-header__logo{height:var(--space-2xl-plus);width:auto;max-width:none}.focus-ring{outline:2px solid transparent;outline-offset:2px}.focus-ring:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offsLine truncated
|
||||
:root{--surface-base:var(--color-surface);--surface-alt:var(--color-surface-alt);--surface-raised:var(--color-card);--surface-raised-private:var(--color-card-private);--surface-raised-critical:var(--color-card-critical);--surface-panel:var(--color-panel);--surface-panel-hover:var(--color-panel-hover);--surface-sunken:var(--color-surface-sunken);--surface-muted:var(--color-surface-muted);--surface-veil:var(--color-overlay-veil);--surface-inverse:var(--color-page);--surface-overlay:var(--color-overlay);--surface-overlay-muted:var(--color-overlay-muted);--surface-overlay-strong:var(--color-overlay-strong);--surface-overlay-darker:var(--color-overlay-darker);--surface-overlay-black:var(--color-overlay-black);--surface-overlay-banner:var(--surface-overlay-banner-bottom);--surface-overlay-banner-bottom:var(--color-overlay-banner-bottom);--surface-overlay-banner-transparent:var(--color-overlay-banner-transparent);--surface-overlay-banner-gradient:linear-gradient(to top,var(--surface-overlay-banner-bottom) 0%,var(--surface-overlay-banner-transparent) 60%,var(--surface-overlay-banner-transparent) 100%);--surface-overlay-panel:var(--color-overlay-panel);--surface-overlay-panel-soft:var(--color-overlay-panel-soft);--surface-overlay-panel-tint:var(--color-overlay-panel-tint);--surface-overlay-panel-glass:var(--color-overlay-panel-glass);--surface-overlay-panel-glass-soft:var(--color-overlay-panel-glass-soft);--surface-modal-backdrop:var(--color-modal-backdrop);--surface-modal:var(--color-modal-surface);--blur-modal-backdrop:18px;--blur-modal-alert:14px;--blur-thumbnail-blur:18px;--modal-nav-backdrop-blur:6px;--border-default:var(--color-border);--border-subtle:var(--color-border-subtle);--border-strong:var(--color-border-translucent-stronger);--border-overlay:var(--color-border-translucent);--border-overlay-strong:var(--color-border-translucent-strongest);--border-overlay-glow:var(--color-border-translucent-glow);--border-neutral-soft:var(--color-border-neutral-soft);--border-info-soft:var(--color-border-info-soft);--border-sidebar-strong:var(--color-border-sidebar-strong);--border-width-hairline:1px;--border-width-fine:0.5px;--text-primary:var(--color-text);--text-inverse:var(--color-text-inverse);--text-muted:var(--color-text-muted);--text-muted-strong:var(--color-muted-strong);--text-subtle:var(--color-subtle-text);--text-frosted-primary:var(--color-text-frosted);--text-frosted-strong:var(--color-text-frosted-strong);--text-frosted-soft:var(--color-text-frosted-soft);--status-info:var(--color-info);--status-info-strong:var(--color-info-strong);--status-info-surface:var(--color-info-glass);--status-info-border:var(--color-info-border);--status-info-on:var(--color-info-on);--status-success:var(--color-success);--status-success-strong:var(--color-success-strong);--status-success-surface:var(--color-success-surface);--status-success-border:var(--color-success-border);--status-success-on:var(--color-success-on);--status-warning:var(--color-warning);--status-warning-strong:var(--color-warning-strong);--status-warning-surface:var(--color-warning-surface);--status-warning-border:var(--color-warning-border);--status-warning-on:var(--color-warning-on);--status-danger:var(--color-critical);--status-danger-strong:var(--color-critical-strong);--status-danger-surface:var(--color-danger-surface);--status-danger-border:var(--color-danger-border);--status-danger-on:var(--color-danger-on);--status-private:var(--color-private);--status-private-strong:var(--color-private-strong);--status-private-surface:var(--color-private-surface);--status-private-border:var(--color-private-border);--status-private-on:var(--color-private-on);--status-neutral:var(--color-muted);--status-neutral-strong:var(--color-muted-strong);--status-neutral-surface:var(--color-neutral-surface);--status-neutral-border:var(--color-neutral-border);--status-neutral-on:var(--color-neutral-on);--scrollbar-width:0.5rem;--scrollbar-track:var(--color-white-soft);--scrollbar-thumb:var(--color-white-softer);--scrollbar-thumb-hover:var(--color-white-softest);--z-overlay-nav:60;--z-overlay-floating:70;--z-overlay-popover:140;--z-overlay-toast:180;--z-sidebar-overlay:10;--z-sidebar:20;--z-modal-root:100;--z-modal-overlay:110;--z-modal-content:120;--z-modal-player:130;--overlay-backdrop-color:var(--color-overlay-backdrop);--overlay-backdrop-blur:12px;--sidebar-width-expanded:16rem;--sidebar-width-collapsed:4rem;--sidebar-width:var(--sidebar-width-expanded);--sidebar-control-size:2.75rem;--sidebar-content-gap:1rem;--space-sidebar-shell-inline:var(--space-lg);--space-sidebar-shell-block-start:var(--space-lg);--space-sidebar-shell-block-end:1.75rem;--space-sidebar-scroll-gap:1.75rem;--space-sidebar-panel-gap:1.35rem;--space-sidebar-panel-padding-block:1.35rem;--space-sidebar-panel-padding-inline:1.1rem;--space-sidebar-footer-gap:0.85rem;--space-sidebar-trigger-padding-block:0.78rem;--space-sidebar-popover-offset:0.85rem;--space-sidebar-popover-padding-block:0.85rem;--space-sidebar-pLine truncated
|
||||
/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:var(--font-family-sans);font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:var(--font-family-mono);font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}html{font-size:100%}@media (min-width:1024px){html{font-size:75%}}*,:after,:before{scrollbar-width:thin;scrollbar-color:var(--scrollbar-thumb) transparent}::-webkit-scrollbar{width:var(--scrollbar-width);height:var(--scrollbar-width)}::-webkit-scrollbar-track{background:var(--scrollbar-track)}::-webkit-scrollbar-thumb{background:var(--scrollbar-thumb);border-radius:var(--radius-sm)}::-webkit-scrollbar-thumb:hover{background:var(--scrollbar-thumb-hover)}body{font-family:var(--font-family-sans);background-color:var(--color-page);color:var(--color-text-strong);line-height:1.5;margin:0;padding:0;overflow-x:hidden}#uiOverlay{position:fixed;inset:0;pointer-events:none;z-index:var(--z-overlay-popover)}#uiOverlay>*{pointer-events:auto}#editNip71TagsFieldset>legend,#nip71TagsFieldset>legend{margin-left:0;padding:0}#app{background-color:var(--color-page);color:inherit}body.modal-open,html.modal-open{overflow:hidden;height:100%}body.modal-open #app{overflow:hidden}body.modal-open #sidebar{pointer-events:none;opacity:0;visibility:hidden;z-index:var(--z-sidebar-overlay)}.ng-cloak,.ng-hide:not(.ng-hide-animate),.x-ng-cloak,[data-ng-cloak],[ng-cloak],[ng\:cloak],[x-ng-cloak]{display:none!important}ng\:form{display:block}.\!container{width:100%!important}.container{width:100%}@media (min-width:25rem){.\!container{max-width:25rem!important}.container{max-width:25rem}}@media (min-width:30rem){.\!container{max-width:30rem!important}.container{max-width:30rem}}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}#playerModal.ds-header{display:flex}.ds-header{display:flex;align-items:center;justify-content:flex-start;padding-top:var(--space-sm);padding-bottom:var(--space-sm)}.ds-metric-probe{position:absolute;top:0;left:0;visibility:hidden;pointer-events:none;contain:layout size style;inline-size:0;block-size:var(--ds-metric-probe-length,0);overflow:hidden}.ds-header__logo{height:var(--space-2xl-plus);width:auto;max-width:none}.focus-ring{outline:2px solid transparent;outline-offset:2px}.focus-ring:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offsLine truncated
|
||||
+145
-1110
File diff suppressed because it is too large.
Load diff
-101
@@ -153,9 +153,6 @@
|
||||
--revert-modal-sidebar-min: 240px;
|
||||
--revert-modal-sidebar-width: 280px;
|
||||
--popover-inline-safe-max: calc(100vw - var(--space-xl));
|
||||
--blog-layout-fluid-width: 80%;
|
||||
--blog-layout-max-width: 47.5rem;
|
||||
--blog-embed-height: auto;
|
||||
|
||||
/* Progress primitives */
|
||||
--progress-height-sm: 0.25rem;
|
||||
@@ -166,52 +163,6 @@
|
||||
--progress-transition-duration: var(--motion-duration-fast);
|
||||
--progress-transition-ease: var(--motion-ease-out);
|
||||
|
||||
/* Blog layout */
|
||||
--blog-font-size-root: 1.25rem;
|
||||
--blog-font-size-display: 3rem;
|
||||
--blog-font-size-title: 1.1rem;
|
||||
--blog-font-size-body: 0.9rem;
|
||||
--blog-font-size-meta: 0.8rem;
|
||||
--blog-font-size-h1-mobile: 1.6rem;
|
||||
--blog-space-compact: 0.3rem;
|
||||
--blog-space-snug: 0.4rem;
|
||||
--blog-space-stack-xxl: 4rem;
|
||||
--blog-space-inline-lg: 2.5rem;
|
||||
--blog-space-media-stack-start: 2.5rem;
|
||||
--blog-space-media-stack-end: 3.5rem;
|
||||
--blog-space-heading-stack: 1.4rem;
|
||||
--blog-space-heading-gap: 0.6rem;
|
||||
--blog-avatar-size: 2.5rem;
|
||||
--blog-avatar-size-compact: 1.5rem;
|
||||
--blog-line-height-body: 1.3rem;
|
||||
--blog-line-height-tight: 0.9;
|
||||
--ztr-font: var(--font-family-sans);
|
||||
--ztr-font-size: var(--font-size-base);
|
||||
--ztr-text-color: var(--color-blog-gray-800);
|
||||
--ztr-link-color: var(--color-blog-purple-700);
|
||||
--ztr-icon-color: var(--color-blog-gray-700);
|
||||
--ztr-background-color: var(--color-blog-background);
|
||||
--ztr-login-button-color: var(--color-blog-gray-800);
|
||||
|
||||
/* Blog carousel tokens */
|
||||
--blog-carousel-gap: var(--space-lg);
|
||||
--blog-carousel-slide-radius: var(--radius-xl);
|
||||
--blog-carousel-slide-min-height: 18rem;
|
||||
--blog-carousel-slide-background: var(--color-blog-neutral-50);
|
||||
--blog-carousel-slide-foreground: var(--color-blog-gray-900);
|
||||
--blog-carousel-slide-scale-inactive: 0.96;
|
||||
--blog-carousel-progress-height: var(--progress-height-sm);
|
||||
--blog-carousel-progress-radius: var(--progress-radius);
|
||||
--blog-carousel-progress-track: var(--color-border-translucent);
|
||||
--blog-carousel-progress-active: var(--color-blog-purple-500);
|
||||
--blog-loader-border-width: 6px;
|
||||
--blog-loader-dimension-lg: 92px;
|
||||
--blog-loader-dimension-md: 64px;
|
||||
--blog-theme-toggle-shadow-offset: 6px;
|
||||
--blog-theme-toggle-shadow: inset calc(var(--blog-theme-toggle-shadow-offset) * -1)
|
||||
calc(var(--blog-theme-toggle-shadow-offset) * -1)
|
||||
var(--border-width-hairline) var(--border-width-hairline) var(--color-card);
|
||||
|
||||
/* Spacing scale */
|
||||
--space-4xs: 0.0625rem;
|
||||
--space-3xs: 0.125rem;
|
||||
@@ -257,8 +208,6 @@
|
||||
--status-log-min-height: 5rem;
|
||||
--layout-player-max-width: 68.75rem;
|
||||
--layout-docs-max-width: 92.5rem;
|
||||
--layout-blog-max-width: 46.875rem;
|
||||
--blog-top-note-image-height: 12.5rem;
|
||||
--icon-size-sm: var(--font-size-lg);
|
||||
--icon-size-md: var(--space-md-plus);
|
||||
--icon-size-lg: var(--space-xl-plus);
|
||||
@@ -363,56 +312,6 @@
|
||||
--motion-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--modal-motion-duration: var(--motion-duration-base);
|
||||
}
|
||||
:root[data-theme="light"],
|
||||
:root[data-theme="dark"],
|
||||
:root:not([data-theme]) {
|
||||
/* Blog palette (theme-agnostic color values) */
|
||||
--color-blog-transparent: rgb(0 0 0 / 0);
|
||||
--color-blog-cyan-500: rgb(6 182 212);
|
||||
--color-blog-sky-500: rgb(0 187 255);
|
||||
--color-blog-teal-600: rgb(14 116 144);
|
||||
--color-blog-green-800: rgb(20 83 45);
|
||||
--color-blog-green-700: rgb(21 128 61);
|
||||
--color-blog-teal-700: rgb(22 78 99);
|
||||
--color-blog-gray-900: rgb(23 23 23);
|
||||
--color-blog-coffee-900: rgb(28 25 23);
|
||||
--color-blog-green-500: rgb(34 197 94);
|
||||
--color-blog-gray-800: rgb(43 43 43);
|
||||
--color-blog-gray-700: rgb(59 59 59);
|
||||
--color-blog-teal-accent: rgb(62 139 138);
|
||||
--color-blog-gray-650: rgb(64 64 64);
|
||||
--color-blog-brown-700: rgb(68 64 60);
|
||||
--color-blog-purple-900: rgb(88 28 135);
|
||||
--color-blog-gray-500: rgb(102 102 102);
|
||||
--color-blog-gray-450: rgb(115 115 115);
|
||||
--color-blog-gray-440: rgb(116 116 116);
|
||||
--color-blog-brown-500: rgb(120 113 108);
|
||||
--color-blog-rust-700: rgb(124 45 18);
|
||||
--color-blog-purple-700: rgb(126 34 206);
|
||||
--color-blog-purple-accent: rgb(146 55 156);
|
||||
--color-blog-purple-muted: rgb(150 96 156);
|
||||
--color-blog-gray-300: rgb(153 153 153);
|
||||
--color-blog-purple-500: rgb(168 85 247);
|
||||
--color-blog-gray-275: rgb(169 169 169);
|
||||
--color-blog-red-600: rgb(185 28 28);
|
||||
--color-blog-rust-600: rgb(194 65 12);
|
||||
--color-blog-error: rgb(202 23 23);
|
||||
--color-blog-gray-220: rgb(203 203 203);
|
||||
--color-blog-gray-210: rgb(204 204 204);
|
||||
--color-blog-gray-200: rgb(222 222 222);
|
||||
--color-blog-amber-500: rgb(228 152 77);
|
||||
--color-blog-gray-190: rgb(224 224 224);
|
||||
--color-blog-orange-600: rgb(227 84 40);
|
||||
--color-blog-gray-180: rgb(233 233 233);
|
||||
--color-blog-gray-170: rgb(235 235 235);
|
||||
--color-blog-red-500: rgb(239 68 68);
|
||||
--color-blog-amber-400: rgb(246 173 85);
|
||||
--color-blog-neutral-100: rgb(245 245 245);
|
||||
--color-blog-neutral-120: rgb(246 246 246);
|
||||
--color-blog-orange-500: rgb(249 115 22);
|
||||
--color-blog-neutral-50: rgb(253 253 253);
|
||||
--color-blog-background: rgb(0 0 0 / 0.03);
|
||||
}
|
||||
:root[data-theme="light"] {
|
||||
/* Light theme palette */
|
||||
--color-page: rgb(247 248 252);
|
||||
|
||||
+2
-50
@@ -57,7 +57,7 @@ Run `npm run lint:tailwind-brackets` after touching markup or prose that embeds
|
||||
sources and fails when Tailwind's bracket syntax introduces hard-coded measurements (`text-[11px]`, `max-w-[18rem]`, etc.).
|
||||
|
||||
- Prefer the semantic utilities shipped alongside the tokens (`max-w-popover-safe`, `max-h-modal-shell`, `text-3xs`).
|
||||
- When you truly need an arbitrary value, drive it with a design token by referencing the CSS custom property (`w-[var(--blog-layout-fluid-width)]`, `min-h-[var(--form-textarea-min-height)]`).
|
||||
- When you truly need an arbitrary value, drive it with a design token by referencing the CSS custom property (`w-[var(--layout-player-max-width)]`, `min-h-[var(--form-textarea-min-height)]`).
|
||||
- Update the allowlist sparingly—every entry should correspond to a documented token so future adjustments cascade automatically.
|
||||
|
||||
The lint shares the same underlying script as `npm run lint:tokens`, so both commands stay in lockstep with the design-system
|
||||
@@ -82,20 +82,6 @@ Tokens are sourced from `css/tokens.css` and consumed as CSS custom properties.
|
||||
|
||||
Limit overrides to variables defined in `css/tokens.css` and document any long-lived changes in this file so the design tokens remain the source of truth. Feature-flagged experiments should wrap overrides in a `data-flag-<name>` selector or toggle them via controller logic so the defaults remain stable when the flag is off.
|
||||
|
||||
### Blog layout tokens
|
||||
|
||||
The blog embed now consumes dedicated typography and spacing tokens alongside Tailwind utilities. `css/tokens.css` exposes the following helpers:
|
||||
|
||||
- `--blog-font-size-root`, `--blog-font-size-display`, `--blog-font-size-title`, `--blog-font-size-body`, `--blog-font-size-meta`, and `--blog-font-size-h1-mobile` scale copy across the blog shell, hero headings, and metadata.
|
||||
- `--blog-line-height-body` and `--blog-line-height-tight` set the rhythm for long-form text and compact headers.
|
||||
- `--blog-layout-fluid-width` and `--blog-layout-max-width` define the responsive container width; pair them with Tailwind utilities (`mx-auto`, `md:w-[var(--blog-layout-fluid-width)]`, `md:max-w-[var(--blog-layout-max-width)]`) when mounting the blog root.
|
||||
- `--blog-space-compact`, `--blog-space-snug`, `--blog-space-heading-stack`, `--blog-space-heading-gap`, `--blog-space-inline-lg`, `--blog-space-media-stack-start`, `--blog-space-media-stack-end`, and `--blog-space-stack-xxl` standardise the bespoke gaps used throughout article summaries, media blocks, and footer treatments.
|
||||
- `--blog-avatar-size` and `--blog-avatar-size-compact` size topic avatars responsively.
|
||||
|
||||
Templates should reference these tokens (directly or via Tailwind arbitrary values) instead of hard-coded measurements so the embed stays in sync with future blog refreshes.
|
||||
|
||||
The blog palette now resolves from the base `:root` scope, making color and layout tokens theme-agnostic. Neither the light nor dark theme overrides these variables, so any future blog-specific deltas should be called out explicitly in this guide if they return.
|
||||
|
||||
## Semantic Palette
|
||||
|
||||
All interactive surfaces now draw from a shared semantic color map exposed through `css/tokens.css` and Tailwind utility aliases. The palette is grouped into text, surface, overlay, and status slots so controllers can swap state styles without reaching for raw Tailwind hues:
|
||||
@@ -112,43 +98,9 @@ All interactive surfaces now draw from a shared semantic color map exposed throu
|
||||
- Use `text-info`/`hover:text-info-strong` for links that previously relied on `text-blue-400` so anchors adapt in both light and dark modes.
|
||||
- When rendering tinted descriptions or placeholders, prefer `text-muted`, `text-muted-strong`, or `text-subtle` over semi-transparent whites. Token overrides keep the same classes usable across the default and light themes.
|
||||
|
||||
### Blog carousel markup
|
||||
|
||||
The short-notes carousel ships as a semantic wrapper around the `blog-carousel` primitive defined in `css/tailwind.source.css`. The runtime no longer injects inline styles or Splide classes—layout is driven entirely by data attributes that map onto tokenised CSS rules.
|
||||
|
||||
**Structure**
|
||||
|
||||
```html
|
||||
<section class="short-notes blog-carousel" data-carousel data-per-page="3" data-index="0">
|
||||
<div class="blog-carousel__viewport" data-carousel-viewport>
|
||||
<ul class="blog-carousel__track" data-carousel-track>
|
||||
<li class="blog-carousel__slide" data-carousel-slide data-index="0" data-active="true">
|
||||
...
|
||||
</li>
|
||||
<!-- additional slides -->
|
||||
</ul>
|
||||
<button type="button" data-carousel-prev data-carousel-control="prev">‹</button>
|
||||
<button type="button" data-carousel-next data-carousel-control="next">›</button>
|
||||
</div>
|
||||
<div class="blog-carousel__progress blog-progress" data-carousel-progress>
|
||||
<progress class="progress progress--blog" data-progress-meter aria-label="Carousel progress"></progress>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
**State contract**
|
||||
|
||||
- `data-per-page` controls how many columns render inside the viewport. CSS exposes variants for 1–4 slides per page; the custom property `--blog-carousel-per-page` updates automatically.
|
||||
- `data-index` represents the active *page* (zero-indexed). The track translates by `100%` for each page, so the attribute should advance in whole-number increments rather than slide indices.
|
||||
- Slides expose `data-index`, `data-active`, `data-inert`, and `data-state`. The carousel controller toggles `data-state="active"`, `"idle"`, and `"leaving"` to drive scale/opacity transitions without touching inline styles.
|
||||
- The root `section` advertises `data-state="active" | "idle"` plus `data-transition="auto" | "instant"` when short-circuiting animations (for example, during first render or programmatic jumps).
|
||||
- Progress wrappers set `data-state="idle" | "active" | "paused" | "complete"` while the inner `<progress>` leverages the shared `progress` primitive (`data-progress-meter`, `data-variant="blog"`) so theme tokens continue to drive track/fill colours.
|
||||
|
||||
Controllers should rely on these data hooks when orchestrating autoplay, pausing on pointer/visibility changes, or syncing the segmented progress indicator. Avoid reintroducing inline `style` mutations—tests enforce the contract via the inline-style guard.
|
||||
|
||||
### Layout & spacing extensions
|
||||
|
||||
The core spacing scale gained intermediate stops for tighter micro-layouts and larger hero treatments. `css/tokens.css` now exports `--space-4xs`, `--space-3xs`, `--space-xs-snug`, `--space-md-plus`, `--space-xl-compact`, `--space-xl-plus`, and `--space-2xl-plus`; Tailwind surfaces matching utilities such as `p-4xs`, `p-3xs`, `px-xs-snug`, `py-md-plus`, `gap-xl-compact`, `px-xl-plus`, and `gap-2xl-plus`. Pair them with the new layout primitives—`--menu-min-width` (`min-w-menu`), `--modal-max-width` (`max-w-modal`), `--layout-player-max-width` (`max-w-player`), and `--layout-docs-max-width` (`max-w-docs`)—to anchor consistent breakpoints across popovers, modals, and documentation shells without reintroducing raw `rem`/`px` literals. Blog-specific chrome reuses the same additions: the theme toggle knob now references `--radius-toggle-thumb`, and focus affordances draw from `--outline-thick-width` instead of hard-coded pixel outlines.
|
||||
The core spacing scale gained intermediate stops for tighter micro-layouts and larger hero treatments. `css/tokens.css` now exports `--space-4xs`, `--space-3xs`, `--space-xs-snug`, `--space-md-plus`, `--space-xl-compact`, `--space-xl-plus`, and `--space-2xl-plus`; Tailwind surfaces matching utilities such as `p-4xs`, `p-3xs`, `px-xs-snug`, `py-md-plus`, `gap-xl-compact`, `px-xl-plus`, and `gap-2xl-plus`. Pair them with the new layout primitives—`--menu-min-width` (`min-w-menu`), `--modal-max-width` (`max-w-modal`), `--layout-player-max-width` (`max-w-player`), and `--layout-docs-max-width` (`max-w-docs`)—to anchor consistent breakpoints across popovers, modals, and documentation shells without reintroducing raw `rem`/`px` literals.
|
||||
|
||||
### Previewing themes
|
||||
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
import { userLogger } from "../utils/logger.js";
|
||||
function toArray(value) {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
return Array.isArray(value) ? value : Array.from(value);
|
||||
}
|
||||
|
||||
const SLIDE_STATE_IDLE = 'idle';
|
||||
const SLIDE_STATE_ACTIVE = 'active';
|
||||
const SLIDE_STATE_LEAVING = 'leaving';
|
||||
const SLIDE_TRANSITION_TIMEOUT = 420;
|
||||
|
||||
function getRange(pageIndex, perPage) {
|
||||
const start = pageIndex * perPage;
|
||||
return { start, end: start + perPage };
|
||||
}
|
||||
|
||||
function clearTimer(state, slide) {
|
||||
const handle = state.timers.get(slide);
|
||||
if (typeof handle === 'number') {
|
||||
clearTimeout(handle);
|
||||
}
|
||||
state.timers.delete(slide);
|
||||
}
|
||||
|
||||
function scheduleSlideIdle(state, slide) {
|
||||
clearTimer(state, slide);
|
||||
const handle = setTimeout(() => {
|
||||
state.timers.delete(slide);
|
||||
if (slide.dataset.active !== 'true') {
|
||||
slide.dataset.state = SLIDE_STATE_IDLE;
|
||||
}
|
||||
}, SLIDE_TRANSITION_TIMEOUT);
|
||||
state.timers.set(slide, handle);
|
||||
}
|
||||
|
||||
function applySlideState(state, slide, { isActive, wasActive, immediate }) {
|
||||
if (isActive) {
|
||||
clearTimer(state, slide);
|
||||
slide.dataset.active = 'true';
|
||||
slide.dataset.state = SLIDE_STATE_ACTIVE;
|
||||
slide.dataset.inert = 'false';
|
||||
slide.setAttribute('aria-hidden', 'false');
|
||||
return;
|
||||
}
|
||||
|
||||
slide.dataset.active = 'false';
|
||||
slide.dataset.inert = 'true';
|
||||
slide.setAttribute('aria-hidden', 'true');
|
||||
|
||||
if (!immediate && wasActive) {
|
||||
slide.dataset.state = SLIDE_STATE_LEAVING;
|
||||
scheduleSlideIdle(state, slide);
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimer(state, slide);
|
||||
slide.dataset.state = SLIDE_STATE_IDLE;
|
||||
}
|
||||
|
||||
function updateSlides(state, { previousIndex, immediate = false } = {}) {
|
||||
const currentRange = getRange(state.index, state.perPage);
|
||||
const previousRange =
|
||||
typeof previousIndex === 'number' ? getRange(previousIndex, state.perPage) : currentRange;
|
||||
|
||||
state.slides.forEach((slide, slideIndex) => {
|
||||
const isActive = slideIndex >= currentRange.start && slideIndex < currentRange.end;
|
||||
const wasActive = slideIndex >= previousRange.start && slideIndex < previousRange.end;
|
||||
applySlideState(state, slide, { isActive, wasActive, immediate });
|
||||
});
|
||||
}
|
||||
|
||||
function syncTransitionMode(container, track, mode) {
|
||||
if (!container || !track) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode) {
|
||||
container.dataset.transition = mode;
|
||||
track.dataset.transition = mode;
|
||||
} else {
|
||||
delete container.dataset.transition;
|
||||
delete track.dataset.transition;
|
||||
}
|
||||
}
|
||||
|
||||
export function createCarousel(container, options = {}) {
|
||||
const track = options.track || container?.querySelector('[data-carousel-track]');
|
||||
const slides = options.slides || toArray(track?.children);
|
||||
if (!container || !track || slides.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const perPage = Math.max(1, Math.min(options.perPage || 1, slides.length));
|
||||
const pageCount = Math.max(1, Math.ceil(slides.length / perPage));
|
||||
|
||||
const state = {
|
||||
container,
|
||||
track,
|
||||
slides,
|
||||
perPage,
|
||||
pageCount,
|
||||
loop: Boolean(options.loop),
|
||||
autoPlayInterval: Math.max(0, options.autoPlayInterval || 0),
|
||||
index: 0,
|
||||
progress: null,
|
||||
cleanup: [],
|
||||
timers: new Map(),
|
||||
setProgress(progressInstance) {
|
||||
this.progress = progressInstance || null;
|
||||
},
|
||||
};
|
||||
|
||||
container.dataset.count = String(pageCount);
|
||||
container.dataset.perPage = String(perPage);
|
||||
container.dataset.index = '0';
|
||||
container.dataset.state = pageCount > 1 ? 'active' : 'idle';
|
||||
|
||||
syncTransitionMode(container, track, 'auto');
|
||||
|
||||
slides.forEach((slide, slideIndex) => {
|
||||
slide.dataset.index = String(slideIndex);
|
||||
slide.dataset.active = 'false';
|
||||
slide.dataset.state = SLIDE_STATE_IDLE;
|
||||
slide.dataset.inert = 'true';
|
||||
slide.setAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
function handlePrev(event) {
|
||||
event?.preventDefault?.();
|
||||
advanceSlide(state, -1);
|
||||
}
|
||||
|
||||
function handleNext(event) {
|
||||
event?.preventDefault?.();
|
||||
advanceSlide(state, 1);
|
||||
}
|
||||
|
||||
if (options.controls?.prev) {
|
||||
options.controls.prev.addEventListener('click', handlePrev);
|
||||
state.cleanup.push(() => options.controls.prev.removeEventListener('click', handlePrev));
|
||||
}
|
||||
|
||||
if (options.controls?.next) {
|
||||
options.controls.next.addEventListener('click', handleNext);
|
||||
state.cleanup.push(() => options.controls.next.removeEventListener('click', handleNext));
|
||||
}
|
||||
|
||||
state.goTo = function goTo(targetPage, { restartProgress = true, instant = false } = {}) {
|
||||
const maxIndex = state.pageCount - 1;
|
||||
let nextIndex = targetPage;
|
||||
|
||||
if (nextIndex < 0) {
|
||||
nextIndex = state.loop ? maxIndex : 0;
|
||||
} else if (nextIndex > maxIndex) {
|
||||
nextIndex = state.loop ? 0 : maxIndex;
|
||||
}
|
||||
|
||||
const previousIndex = state.index;
|
||||
state.index = nextIndex;
|
||||
|
||||
container.dataset.index = String(nextIndex);
|
||||
|
||||
syncTransitionMode(container, track, instant ? 'instant' : 'auto');
|
||||
|
||||
updateSlides(state, { previousIndex, immediate: instant });
|
||||
|
||||
if (instant) {
|
||||
syncTransitionMode(container, track, 'auto');
|
||||
}
|
||||
|
||||
if (restartProgress && state.autoPlayInterval > 0 && state.progress) {
|
||||
state.progress.reset();
|
||||
state.progress.start(state.autoPlayInterval);
|
||||
}
|
||||
};
|
||||
|
||||
state.goTo(0, { restartProgress: false, instant: true });
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export function advanceSlide(state, step = 1, options = {}) {
|
||||
if (!state || typeof state.goTo !== 'function') {
|
||||
return;
|
||||
}
|
||||
state.goTo(state.index + step, options);
|
||||
}
|
||||
|
||||
export function destroyCarousel(state) {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.cleanup?.forEach((dispose) => {
|
||||
try {
|
||||
dispose();
|
||||
} catch (error) {
|
||||
userLogger.warn('Failed to dispose carousel listener', error);
|
||||
}
|
||||
});
|
||||
state.cleanup = [];
|
||||
|
||||
for (const handle of state.timers.values()) {
|
||||
clearTimeout(handle);
|
||||
}
|
||||
state.timers.clear();
|
||||
|
||||
if (state.progress && typeof state.progress.destroy === 'function') {
|
||||
state.progress.destroy();
|
||||
}
|
||||
|
||||
if (state.container) {
|
||||
delete state.container.dataset.index;
|
||||
delete state.container.dataset.count;
|
||||
delete state.container.dataset.perPage;
|
||||
delete state.container.dataset.state;
|
||||
delete state.container.dataset.transition;
|
||||
}
|
||||
|
||||
if (state.track) {
|
||||
delete state.track.dataset.transition;
|
||||
}
|
||||
|
||||
state.slides?.forEach((slide) => {
|
||||
delete slide.dataset.active;
|
||||
delete slide.dataset.state;
|
||||
delete slide.dataset.inert;
|
||||
slide.setAttribute('aria-hidden', 'false');
|
||||
});
|
||||
}
|
||||
-1123
File diff suppressed because it is too large.
Load diff
@@ -1,126 +0,0 @@
|
||||
export function mountProgressBar(
|
||||
container,
|
||||
{ initialDuration = 0, autoStart = false, onComplete } = {},
|
||||
) {
|
||||
if (!container) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'blog-carousel__progress blog-progress mt-2';
|
||||
wrapper.dataset.state = 'idle';
|
||||
wrapper.setAttribute('data-carousel-progress', 'true');
|
||||
|
||||
const meter = document.createElement('progress');
|
||||
meter.className = 'progress progress--blog';
|
||||
meter.max = 100;
|
||||
meter.value = 0;
|
||||
meter.dataset.progress = '0';
|
||||
meter.dataset.state = 'idle';
|
||||
meter.setAttribute('data-progress-meter', 'true');
|
||||
meter.setAttribute('data-variant', 'blog');
|
||||
meter.setAttribute('aria-label', 'Carousel progress');
|
||||
meter.setAttribute('aria-valuetext', 'Carousel progress 0% complete');
|
||||
wrapper.appendChild(meter);
|
||||
container.appendChild(wrapper);
|
||||
|
||||
let duration = Math.max(0, initialDuration);
|
||||
let rafId = null;
|
||||
let startTimestamp = null;
|
||||
let completeHandler = typeof onComplete === 'function' ? onComplete : null;
|
||||
|
||||
function cancelAnimation() {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setState(state) {
|
||||
wrapper.dataset.state = state;
|
||||
meter.dataset.state = state;
|
||||
}
|
||||
|
||||
function updateMeter(ratio) {
|
||||
const clamped = Math.max(0, Math.min(1, ratio));
|
||||
const percent = Math.round(clamped * 100);
|
||||
meter.value = percent;
|
||||
meter.dataset.progress = String(percent);
|
||||
meter.setAttribute('aria-valuetext', `Carousel progress ${percent}% complete`);
|
||||
return clamped;
|
||||
}
|
||||
|
||||
function stop() {
|
||||
cancelAnimation();
|
||||
startTimestamp = null;
|
||||
setState('paused');
|
||||
}
|
||||
|
||||
function reset() {
|
||||
stop();
|
||||
updateMeter(0);
|
||||
setState('idle');
|
||||
}
|
||||
|
||||
function step(timestamp) {
|
||||
if (startTimestamp === null) {
|
||||
startTimestamp = timestamp;
|
||||
}
|
||||
const elapsed = timestamp - startTimestamp;
|
||||
const ratio = duration > 0 ? Math.min(1, elapsed / duration) : 1;
|
||||
const progress = updateMeter(ratio);
|
||||
|
||||
if (progress >= 1) {
|
||||
cancelAnimation();
|
||||
startTimestamp = null;
|
||||
setState('complete');
|
||||
if (completeHandler) {
|
||||
completeHandler();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setState('active');
|
||||
rafId = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
function start(nextDuration = duration) {
|
||||
duration = Math.max(0, nextDuration);
|
||||
reset();
|
||||
if (duration === 0) {
|
||||
updateMeter(1);
|
||||
setState('complete');
|
||||
if (completeHandler) {
|
||||
completeHandler();
|
||||
}
|
||||
return;
|
||||
}
|
||||
setState('active');
|
||||
rafId = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
cancelAnimation();
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
function setOnComplete(handler) {
|
||||
completeHandler = typeof handler === 'function' ? handler : null;
|
||||
}
|
||||
|
||||
if (autoStart && duration > 0) {
|
||||
start(duration);
|
||||
} else {
|
||||
reset();
|
||||
}
|
||||
|
||||
return {
|
||||
element: wrapper,
|
||||
meter,
|
||||
start,
|
||||
stop,
|
||||
reset,
|
||||
destroy,
|
||||
setOnComplete,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -6,6 +6,7 @@ import {
|
||||
ADMIN_SUPER_NPUB,
|
||||
ADMIN_DM_IMAGE_URL,
|
||||
BITVID_WEBSITE_URL,
|
||||
BLOG_URL,
|
||||
MAX_WALLET_DEFAULT_ZAP,
|
||||
PLATFORM_FEE_PERCENT,
|
||||
PLATFORM_LUD16_OVERRIDE,
|
||||
@@ -43,6 +44,7 @@ export const ADMIN_LIST_MODE = "nostr";
|
||||
export { ADMIN_SUPER_NPUB };
|
||||
export { ADMIN_DM_IMAGE_URL };
|
||||
export { BITVID_WEBSITE_URL };
|
||||
export { BLOG_URL };
|
||||
export { MAX_WALLET_DEFAULT_ZAP };
|
||||
export { PLATFORM_FEE_PERCENT };
|
||||
export { PLATFORM_LUD16_OVERRIDE };
|
||||
|
||||
+10
@@ -19,6 +19,7 @@ import {
|
||||
openStaticModal,
|
||||
closeStaticModal,
|
||||
} from "./ui/components/staticModalAccessibility.js";
|
||||
import { BLOG_URL } from "./config.js";
|
||||
|
||||
validateInstanceConfig();
|
||||
|
||||
@@ -209,6 +210,15 @@ async function bootstrapInterface() {
|
||||
await loadSidebar("components/sidebar.html", "sidebarContainer");
|
||||
devLogger.log("Sidebar loaded.");
|
||||
|
||||
const blogLink = document.querySelector("[data-blog-link]");
|
||||
if (blogLink instanceof HTMLAnchorElement) {
|
||||
blogLink.href = BLOG_URL;
|
||||
blogLink.target = "_blank";
|
||||
blogLink.rel = "noopener noreferrer";
|
||||
} else {
|
||||
userLogger.warn("Sidebar blog link not found; skipping external link binding.");
|
||||
}
|
||||
|
||||
const sidebar = document.getElementById("sidebar");
|
||||
const collapseToggle = document.getElementById("sidebarCollapseToggle");
|
||||
if (sidebar && !sidebar.hasAttribute("data-footer-state")) {
|
||||
|
||||
@@ -9,7 +9,7 @@ const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
|
||||
const TARGET_DIRECTORIES = ["js/blog", "js/ui", "torrent/ui"];
|
||||
const TARGET_DIRECTORIES = ["js/ui", "torrent/ui"];
|
||||
const TARGET_FILES = ["js/channelProfile.js", "css/tailwind.source.css"];
|
||||
const IGNORED_DIRECTORIES = new Set(["dist", "vendor"]);
|
||||
const IGNORED_EXTENSIONS = new Set([".min.js", ".map"]);
|
||||
|
||||
+2
-53
@@ -3,7 +3,6 @@ const typography = require("@tailwindcss/typography");
|
||||
module.exports = {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./blog.html",
|
||||
"./components/**/*.html",
|
||||
"./views/**/*.html",
|
||||
"./js/**/*.js",
|
||||
@@ -158,53 +157,7 @@ module.exports = {
|
||||
700: "var(--color-neutral-700)"
|
||||
},
|
||||
white: "var(--color-white)",
|
||||
black: "var(--color-black)",
|
||||
blog: {
|
||||
transparent: "var(--color-blog-transparent)",
|
||||
"cyan-500": "var(--color-blog-cyan-500)",
|
||||
"sky-500": "var(--color-blog-sky-500)",
|
||||
"teal-600": "var(--color-blog-teal-600)",
|
||||
"green-800": "var(--color-blog-green-800)",
|
||||
"green-700": "var(--color-blog-green-700)",
|
||||
"teal-700": "var(--color-blog-teal-700)",
|
||||
"gray-900": "var(--color-blog-gray-900)",
|
||||
"coffee-900": "var(--color-blog-coffee-900)",
|
||||
"green-500": "var(--color-blog-green-500)",
|
||||
"gray-800": "var(--color-blog-gray-800)",
|
||||
"gray-700": "var(--color-blog-gray-700)",
|
||||
"teal-accent": "var(--color-blog-teal-accent)",
|
||||
"gray-650": "var(--color-blog-gray-650)",
|
||||
"brown-700": "var(--color-blog-brown-700)",
|
||||
"purple-900": "var(--color-blog-purple-900)",
|
||||
"gray-500": "var(--color-blog-gray-500)",
|
||||
"gray-450": "var(--color-blog-gray-450)",
|
||||
"gray-440": "var(--color-blog-gray-440)",
|
||||
"brown-500": "var(--color-blog-brown-500)",
|
||||
"rust-700": "var(--color-blog-rust-700)",
|
||||
"purple-700": "var(--color-blog-purple-700)",
|
||||
"purple-accent": "var(--color-blog-purple-accent)",
|
||||
"purple-muted": "var(--color-blog-purple-muted)",
|
||||
"gray-300": "var(--color-blog-gray-300)",
|
||||
"purple-500": "var(--color-blog-purple-500)",
|
||||
"gray-275": "var(--color-blog-gray-275)",
|
||||
"red-600": "var(--color-blog-red-600)",
|
||||
"rust-600": "var(--color-blog-rust-600)",
|
||||
error: "var(--color-blog-error)",
|
||||
"gray-220": "var(--color-blog-gray-220)",
|
||||
"gray-210": "var(--color-blog-gray-210)",
|
||||
"gray-200": "var(--color-blog-gray-200)",
|
||||
"amber-500": "var(--color-blog-amber-500)",
|
||||
"gray-190": "var(--color-blog-gray-190)",
|
||||
"orange-600": "var(--color-blog-orange-600)",
|
||||
"gray-180": "var(--color-blog-gray-180)",
|
||||
"gray-170": "var(--color-blog-gray-170)",
|
||||
"red-500": "var(--color-blog-red-500)",
|
||||
"amber-400": "var(--color-blog-amber-400)",
|
||||
"neutral-100": "var(--color-blog-neutral-100)",
|
||||
"neutral-120": "var(--color-blog-neutral-120)",
|
||||
"orange-500": "var(--color-blog-orange-500)",
|
||||
"neutral-50": "var(--color-blog-neutral-50)"
|
||||
}
|
||||
black: "var(--color-black)"
|
||||
},
|
||||
textColor: {
|
||||
primary: "var(--text-primary)",
|
||||
@@ -351,15 +304,11 @@ module.exports = {
|
||||
menu: "var(--menu-min-width)",
|
||||
"grid-card": "var(--grid-card-min-width)"
|
||||
},
|
||||
width: {
|
||||
"blog-fluid": "var(--blog-layout-fluid-width)"
|
||||
},
|
||||
maxWidth: {
|
||||
modal: "var(--modal-max-width)",
|
||||
player: "var(--layout-player-max-width)",
|
||||
docs: "var(--layout-docs-max-width)",
|
||||
"popover-safe": "var(--popover-inline-safe-max)",
|
||||
"blog-shell": "var(--blog-layout-max-width)"
|
||||
"popover-safe": "var(--popover-inline-safe-max)"
|
||||
},
|
||||
translate: {
|
||||
toast: "var(--toast-translate)"
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { beforeEach, afterEach, describe, test } from 'node:test';
|
||||
|
||||
import { advanceSlide, createCarousel, destroyCarousel } from '../js/blog/carousel.js';
|
||||
import { mountProgressBar } from '../js/blog/progressBar.js';
|
||||
import { createBlogDomEnvironment, createSpy } from './test-helpers/blog-dom.mjs';
|
||||
|
||||
function assertHasClasses(element, classes, messagePrefix = 'element') {
|
||||
classes.forEach((className) => {
|
||||
assert.ok(
|
||||
element.classList.contains(className),
|
||||
`${messagePrefix} should include the “${className}” class`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function assertNoInlineStyleAttributes(elements, messagePrefix = 'element') {
|
||||
elements.forEach((element, index) => {
|
||||
assert.ok(element, `${messagePrefix} at index ${index} should exist`);
|
||||
assert.strictEqual(
|
||||
element.hasAttribute('style'),
|
||||
false,
|
||||
`${messagePrefix} at index ${index} should not set inline style attributes`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const CAROUSEL_HTML = `
|
||||
<section class="blog-carousel" data-carousel>
|
||||
<div data-carousel-viewport>
|
||||
<ul data-carousel-track>
|
||||
<li data-carousel-slide>Slide 1</li>
|
||||
<li data-carousel-slide>Slide 2</li>
|
||||
<li data-carousel-slide>Slide 3</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button type="button" data-carousel-prev>Prev</button>
|
||||
<button type="button" data-carousel-next>Next</button>
|
||||
</section>
|
||||
`;
|
||||
|
||||
const PROGRESS_HTML = '<div id="progress-root"></div>';
|
||||
|
||||
describe('blog carousel', () => {
|
||||
let env;
|
||||
let container;
|
||||
let track;
|
||||
let slides;
|
||||
let prevButton;
|
||||
let nextButton;
|
||||
|
||||
beforeEach(() => {
|
||||
env = createBlogDomEnvironment(CAROUSEL_HTML);
|
||||
const doc = env.document;
|
||||
container = doc.querySelector('[data-carousel]');
|
||||
track = container.querySelector('[data-carousel-track]');
|
||||
slides = Array.from(track.children);
|
||||
prevButton = container.querySelector('[data-carousel-prev]');
|
||||
nextButton = container.querySelector('[data-carousel-next]');
|
||||
|
||||
env.inlineStyleGuard.guard(container);
|
||||
env.inlineStyleGuard.guard(track);
|
||||
env.inlineStyleGuard.guard(prevButton);
|
||||
env.inlineStyleGuard.guard(nextButton);
|
||||
slides.forEach((slide) => env.inlineStyleGuard.guard(slide));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
env.cleanup();
|
||||
});
|
||||
|
||||
test('initializes slides with data attributes and accessibility state', () => {
|
||||
const state = createCarousel(container, {
|
||||
perPage: 1,
|
||||
loop: true,
|
||||
controls: { prev: prevButton, next: nextButton },
|
||||
});
|
||||
|
||||
assert.ok(state, 'state should be created when container markup is valid');
|
||||
assert.strictEqual(state.perPage, 1);
|
||||
assert.strictEqual(state.slides.length, 3);
|
||||
assert.strictEqual(state.pageCount, 3);
|
||||
|
||||
assert.strictEqual(container.dataset.index, '0');
|
||||
assert.strictEqual(container.dataset.perPage, '1');
|
||||
assert.strictEqual(container.dataset.count, '3');
|
||||
assert.strictEqual(container.dataset.state, 'active');
|
||||
assert.strictEqual(track.dataset.transition, 'auto');
|
||||
|
||||
assertNoInlineStyleAttributes([container, track, ...slides], 'carousel element');
|
||||
|
||||
slides.forEach((slide, index) => {
|
||||
assert.strictEqual(slide.dataset.index, String(index));
|
||||
assert.strictEqual(slide.dataset.active, index === 0 ? 'true' : 'false');
|
||||
assert.strictEqual(slide.dataset.inert, index === 0 ? 'false' : 'true');
|
||||
assert.strictEqual(slide.getAttribute('aria-hidden'), index === 0 ? 'false' : 'true');
|
||||
});
|
||||
|
||||
state.goTo(2);
|
||||
assert.strictEqual(state.index, 2);
|
||||
assert.strictEqual(container.dataset.index, '2');
|
||||
assert.strictEqual(slides[2].dataset.active, 'true');
|
||||
assert.strictEqual(slides[0].dataset.active, 'false');
|
||||
assert.strictEqual(slides[2].getAttribute('aria-hidden'), 'false');
|
||||
assert.strictEqual(slides[0].getAttribute('aria-hidden'), 'true');
|
||||
|
||||
destroyCarousel(state);
|
||||
});
|
||||
|
||||
test('control buttons advance slides and respect looping', () => {
|
||||
const state = createCarousel(container, {
|
||||
perPage: 1,
|
||||
loop: true,
|
||||
controls: { prev: prevButton, next: nextButton },
|
||||
});
|
||||
|
||||
nextButton.dispatchEvent(new env.window.MouseEvent('click', { bubbles: true }));
|
||||
assert.strictEqual(state.index, 1);
|
||||
nextButton.dispatchEvent(new env.window.MouseEvent('click', { bubbles: true }));
|
||||
assert.strictEqual(state.index, 2);
|
||||
nextButton.dispatchEvent(new env.window.MouseEvent('click', { bubbles: true }));
|
||||
assert.strictEqual(state.index, 0, 'looping should wrap forward navigation to the first slide');
|
||||
|
||||
prevButton.dispatchEvent(new env.window.MouseEvent('click', { bubbles: true }));
|
||||
assert.strictEqual(state.index, 2, 'looping should wrap backward navigation to the last slide');
|
||||
|
||||
destroyCarousel(state);
|
||||
});
|
||||
|
||||
test('auto play progress resets when navigation occurs', () => {
|
||||
const state = createCarousel(container, {
|
||||
perPage: 1,
|
||||
controls: { prev: prevButton, next: nextButton },
|
||||
});
|
||||
const events = [];
|
||||
const progress = {
|
||||
reset: () => events.push('reset'),
|
||||
start: (duration) => events.push(['start', duration]),
|
||||
destroy: () => events.push('destroy'),
|
||||
};
|
||||
state.setProgress(progress);
|
||||
state.autoPlayInterval = 4200;
|
||||
|
||||
state.goTo(1);
|
||||
assert.deepStrictEqual(events, ['reset', ['start', 4200]]);
|
||||
|
||||
events.length = 0;
|
||||
state.goTo(2, { restartProgress: false });
|
||||
assert.deepStrictEqual(events, [], 'progress should not restart when restartProgress is false');
|
||||
|
||||
destroyCarousel(state);
|
||||
});
|
||||
|
||||
test('destroyCarousel tears down listeners and progress state', () => {
|
||||
const state = createCarousel(container, {
|
||||
perPage: 1,
|
||||
controls: { prev: prevButton, next: nextButton },
|
||||
});
|
||||
const progressEvents = [];
|
||||
const progress = {
|
||||
reset: () => progressEvents.push('reset'),
|
||||
start: () => progressEvents.push('start'),
|
||||
destroy: () => progressEvents.push('destroy'),
|
||||
};
|
||||
state.setProgress(progress);
|
||||
state.autoPlayInterval = 2000;
|
||||
state.goTo(1);
|
||||
|
||||
destroyCarousel(state);
|
||||
assert.ok(progressEvents.includes('destroy'), 'progress.destroy should be called during teardown');
|
||||
assert.ok(!container.classList.contains('is-initialized'));
|
||||
assert.ok(!container.classList.contains('is-active'));
|
||||
assert.strictEqual(state.cleanup.length, 0);
|
||||
|
||||
assertNoInlineStyleAttributes([container, track, ...slides], 'carousel element');
|
||||
|
||||
const previousIndex = state.index;
|
||||
nextButton.dispatchEvent(new env.window.MouseEvent('click', { bubbles: true }));
|
||||
assert.strictEqual(state.index, previousIndex, 'clicks after destroy should not change the index');
|
||||
assert.strictEqual(container.dataset.index, undefined);
|
||||
});
|
||||
|
||||
test('advanceSlide delegates to goTo with relative offsets', () => {
|
||||
const state = createCarousel(container, { perPage: 1 });
|
||||
const goToSpy = createSpy();
|
||||
state.goTo = goToSpy;
|
||||
state.index = 2;
|
||||
|
||||
advanceSlide(state, -1, { restartProgress: false });
|
||||
assert.strictEqual(goToSpy.callCount, 1);
|
||||
assert.deepStrictEqual(goToSpy.calls[0], [1, { restartProgress: false }]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('progress bar', () => {
|
||||
let env;
|
||||
let container;
|
||||
|
||||
beforeEach(() => {
|
||||
env = createBlogDomEnvironment(PROGRESS_HTML);
|
||||
container = env.document.getElementById('progress-root');
|
||||
env.inlineStyleGuard.guard(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
env.cleanup();
|
||||
});
|
||||
|
||||
test('mountProgressBar renders semantic progress meter without style injection', () => {
|
||||
const progress = mountProgressBar(container, { initialDuration: 500, autoStart: false });
|
||||
assert.ok(progress, 'progress instance should be returned');
|
||||
|
||||
const wrapper = container.querySelector('.blog-carousel__progress');
|
||||
assert.ok(wrapper, 'wrapper element should be appended');
|
||||
assertHasClasses(wrapper, ['blog-carousel__progress', 'blog-progress', 'mt-2'], 'progress wrapper');
|
||||
assert.strictEqual(wrapper.dataset.carouselProgress, 'true');
|
||||
assert.strictEqual(wrapper.getAttribute('data-carousel-progress'), 'true');
|
||||
const meter = wrapper.querySelector('progress.progress--blog');
|
||||
assert.ok(meter, 'progress meter should be created');
|
||||
assertHasClasses(meter, ['progress', 'progress--blog'], 'progress meter');
|
||||
assert.strictEqual(meter.max, 100, 'progress meter should expose a 0-100 range');
|
||||
assert.strictEqual(meter.value, 0, 'progress meter should start at zero');
|
||||
assert.strictEqual(meter.dataset.state, 'idle');
|
||||
assert.strictEqual(wrapper.dataset.state, 'idle');
|
||||
assert.strictEqual(meter.dataset.progress, '0');
|
||||
assert.strictEqual(
|
||||
meter.getAttribute('aria-valuetext'),
|
||||
'Carousel progress 0% complete',
|
||||
'progress meter should emit accessible status text',
|
||||
);
|
||||
assert.strictEqual(meter.getAttribute('data-progress-meter'), 'true');
|
||||
assert.strictEqual(meter.dataset.progressMeter, 'true');
|
||||
assert.strictEqual(meter.getAttribute('data-variant'), 'blog');
|
||||
|
||||
progress.reset();
|
||||
|
||||
assertNoInlineStyleAttributes([container, wrapper, meter], 'progress element');
|
||||
});
|
||||
|
||||
test('autoStart runs animation and triggers completion callback', () => {
|
||||
const onComplete = createSpy();
|
||||
const progress = mountProgressBar(container, { initialDuration: 600, autoStart: true, onComplete });
|
||||
const wrapper = progress.element;
|
||||
const meter = wrapper.querySelector('progress.progress--blog');
|
||||
assert.ok(meter, 'progress meter should exist when autoStart is enabled');
|
||||
|
||||
assertNoInlineStyleAttributes([container, wrapper, meter], 'progress element');
|
||||
|
||||
env.raf.step(0);
|
||||
env.raf.step(300);
|
||||
assert.strictEqual(onComplete.callCount, 0);
|
||||
assert.ok(meter.value > 0 && meter.value < 100, 'meter should report intermediate progress');
|
||||
assert.strictEqual(meter.dataset.state, 'active');
|
||||
|
||||
env.raf.step(300);
|
||||
assert.strictEqual(onComplete.callCount, 1);
|
||||
assert.strictEqual(meter.value, 100);
|
||||
assert.strictEqual(meter.dataset.state, 'complete');
|
||||
assert.strictEqual(meter.dataset.progress, '100');
|
||||
});
|
||||
|
||||
test('manual controls support stop, restart, and cleanup', () => {
|
||||
const onComplete = createSpy();
|
||||
const progress = mountProgressBar(container, { initialDuration: 800, onComplete });
|
||||
const meter = progress.element.querySelector('progress.progress--blog');
|
||||
|
||||
progress.start(800);
|
||||
env.raf.step(0);
|
||||
env.raf.step(400);
|
||||
assert.strictEqual(meter.value, 50, 'halfway through should yield a 50% reading');
|
||||
assert.strictEqual(meter.dataset.state, 'active');
|
||||
|
||||
progress.stop();
|
||||
assert.strictEqual(meter.dataset.state, 'paused');
|
||||
const pausedValue = meter.value;
|
||||
|
||||
env.raf.step(600);
|
||||
assert.strictEqual(meter.value, pausedValue, 'progress should not advance after stop');
|
||||
assert.strictEqual(onComplete.callCount, 0, 'stop should cancel pending completion');
|
||||
|
||||
progress.setOnComplete(onComplete);
|
||||
progress.start(0);
|
||||
assert.strictEqual(onComplete.callCount, 1, 'zero duration should fire completion immediately');
|
||||
assert.strictEqual(meter.value, 100);
|
||||
assert.strictEqual(meter.dataset.state, 'complete');
|
||||
|
||||
progress.destroy();
|
||||
assert.strictEqual(container.children.length, 0, 'destroy should remove wrapper from DOM');
|
||||
assert.strictEqual(
|
||||
container.querySelector('[data-carousel-progress]'),
|
||||
null,
|
||||
'destroy should remove declarative progress markup',
|
||||
);
|
||||
assertNoInlineStyleAttributes([container], 'progress container');
|
||||
});
|
||||
});
|
||||
@@ -1,205 +0,0 @@
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
const DOM_GLOBALS = [
|
||||
'window',
|
||||
'document',
|
||||
'HTMLElement',
|
||||
'HTMLStyleElement',
|
||||
'Element',
|
||||
'Node',
|
||||
'Event',
|
||||
'MouseEvent',
|
||||
'CustomEvent',
|
||||
'KeyboardEvent',
|
||||
'MutationObserver',
|
||||
'getComputedStyle',
|
||||
];
|
||||
|
||||
export function createSpy(implementation = () => {}) {
|
||||
function spy(...args) {
|
||||
spy.calls.push(args);
|
||||
spy.callCount += 1;
|
||||
return implementation(...args);
|
||||
}
|
||||
spy.calls = [];
|
||||
spy.callCount = 0;
|
||||
return spy;
|
||||
}
|
||||
|
||||
export function createAnimationFrameStub() {
|
||||
let now = 0;
|
||||
let handle = 0;
|
||||
const queue = new Map();
|
||||
|
||||
function requestAnimationFrame(callback) {
|
||||
handle += 1;
|
||||
const id = handle;
|
||||
queue.set(id, callback);
|
||||
return id;
|
||||
}
|
||||
|
||||
function cancelAnimationFrame(id) {
|
||||
queue.delete(id);
|
||||
}
|
||||
|
||||
function step(elapsed = 0) {
|
||||
now += elapsed;
|
||||
const callbacks = Array.from(queue.entries());
|
||||
queue.clear();
|
||||
for (const [, callback] of callbacks) {
|
||||
callback(now);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
requestAnimationFrame,
|
||||
cancelAnimationFrame,
|
||||
step,
|
||||
get now() {
|
||||
return now;
|
||||
},
|
||||
get pending() {
|
||||
return queue.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function installInlineStyleGuard(document) {
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
const ElementConstructor = document.defaultView?.Element;
|
||||
const guardedElements = new WeakSet();
|
||||
const attributePatches = [];
|
||||
|
||||
if (ElementConstructor?.prototype) {
|
||||
const prototype = ElementConstructor.prototype;
|
||||
|
||||
if (typeof prototype.setAttribute === 'function') {
|
||||
const original = prototype.setAttribute;
|
||||
prototype.setAttribute = function inlineStyleSafeSetAttribute(name, value) {
|
||||
if (typeof name === 'string' && name.toLowerCase() === 'style') {
|
||||
throw new Error(
|
||||
'Inline style attributes are disabled in blog widget tests. Use CSS classes or data attributes instead.',
|
||||
);
|
||||
}
|
||||
return original.call(this, name, value);
|
||||
};
|
||||
attributePatches.push(() => {
|
||||
prototype.setAttribute = original;
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof prototype.setAttributeNS === 'function') {
|
||||
const original = prototype.setAttributeNS;
|
||||
prototype.setAttributeNS = function inlineStyleSafeSetAttributeNS(namespace, name, value) {
|
||||
if (typeof name === 'string' && name.toLowerCase() === 'style') {
|
||||
throw new Error(
|
||||
'Inline style attributes are disabled in blog widget tests. Use CSS classes or data attributes instead.',
|
||||
);
|
||||
}
|
||||
return original.call(this, namespace, name, value);
|
||||
};
|
||||
attributePatches.push(() => {
|
||||
prototype.setAttributeNS = original;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function guard(element) {
|
||||
if (!element || typeof element !== 'object') {
|
||||
return element;
|
||||
}
|
||||
|
||||
if (guardedElements.has(element)) {
|
||||
return element;
|
||||
}
|
||||
|
||||
Object.defineProperty(element, 'style', {
|
||||
get() {
|
||||
throw new Error(
|
||||
'Inline style access is disabled in blog widget tests. Use CSS classes or data attributes instead.',
|
||||
);
|
||||
},
|
||||
set() {
|
||||
throw new Error(
|
||||
'Inline style assignment is disabled in blog widget tests. Use CSS classes or data attributes instead.',
|
||||
);
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
guardedElements.add(element);
|
||||
|
||||
if (element.children?.length) {
|
||||
Array.from(element.children).forEach((child) => guard(child));
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
document.createElement = function createElementGuarded(...args) {
|
||||
const element = originalCreateElement(...args);
|
||||
guard(element);
|
||||
return element;
|
||||
};
|
||||
|
||||
return {
|
||||
guard,
|
||||
restore() {
|
||||
document.createElement = originalCreateElement;
|
||||
attributePatches.forEach((restorePatch) => {
|
||||
try {
|
||||
restorePatch();
|
||||
} catch (error) {
|
||||
// ignore failures to restore in tests
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createBlogDomEnvironment(html) {
|
||||
const dom = new JSDOM(html, { url: 'https://example.com' });
|
||||
const { window } = dom;
|
||||
const previousGlobals = new Map();
|
||||
|
||||
for (const key of DOM_GLOBALS) {
|
||||
previousGlobals.set(key, globalThis[key]);
|
||||
const nextValue = window[key];
|
||||
if (typeof nextValue !== 'undefined') {
|
||||
globalThis[key] = nextValue;
|
||||
} else {
|
||||
delete globalThis[key];
|
||||
}
|
||||
}
|
||||
|
||||
const inlineStyleGuard = installInlineStyleGuard(window.document);
|
||||
const raf = createAnimationFrameStub();
|
||||
|
||||
previousGlobals.set('requestAnimationFrame', globalThis.requestAnimationFrame);
|
||||
previousGlobals.set('cancelAnimationFrame', globalThis.cancelAnimationFrame);
|
||||
|
||||
globalThis.requestAnimationFrame = raf.requestAnimationFrame;
|
||||
globalThis.cancelAnimationFrame = raf.cancelAnimationFrame;
|
||||
window.requestAnimationFrame = raf.requestAnimationFrame;
|
||||
window.cancelAnimationFrame = raf.cancelAnimationFrame;
|
||||
|
||||
return {
|
||||
window,
|
||||
document: window.document,
|
||||
dom,
|
||||
raf,
|
||||
inlineStyleGuard,
|
||||
cleanup() {
|
||||
inlineStyleGuard.restore();
|
||||
dom.window.close();
|
||||
|
||||
for (const [key, value] of previousGlobals) {
|
||||
if (typeof value === 'undefined') {
|
||||
delete globalThis[key];
|
||||
} else {
|
||||
globalThis[key] = value;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
-129
@@ -1,129 +0,0 @@
|
||||
<!-- views/blog.html -->
|
||||
<section class="bv-stack" aria-labelledby="blogHeading">
|
||||
<article class="card p-lg">
|
||||
<div class="bv-stack bv-stack--tight">
|
||||
<h2 id="blogHeading" class="text-2xl font-semibold text-text-strong">
|
||||
Latest Blog Posts
|
||||
</h2>
|
||||
<div class="blog-embed-shell relative isolate overflow-hidden rounded-lg bg-surface-alt">
|
||||
<iframe
|
||||
id="blog-iframe"
|
||||
src="https://beta.bitvid.network/blog.html"
|
||||
class="w-full border-none"
|
||||
></iframe>
|
||||
<script type="module">
|
||||
const iframe = document.querySelector('#blog-iframe');
|
||||
if (iframe) {
|
||||
const abortController = new AbortController();
|
||||
const { signal } = abortController;
|
||||
|
||||
const targetOrigin = (() => {
|
||||
try {
|
||||
return new URL(iframe.src, window.location.href).origin;
|
||||
} catch (error) {
|
||||
return window.location.origin;
|
||||
}
|
||||
})();
|
||||
|
||||
const applyHeight = (height) => {
|
||||
const numeric = Math.max(0, Math.round(Number(height)));
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return;
|
||||
}
|
||||
iframe.style.height = `${numeric}px`;
|
||||
iframe.style.removeProperty('min-height');
|
||||
iframe.style.removeProperty('max-height');
|
||||
};
|
||||
|
||||
let receivedFirstHeight = false;
|
||||
let retryIntervalId = null;
|
||||
|
||||
const stopRequestLoop = () => {
|
||||
if (retryIntervalId === null) {
|
||||
return;
|
||||
}
|
||||
window.clearInterval(retryIntervalId);
|
||||
retryIntervalId = null;
|
||||
};
|
||||
|
||||
const startRequestLoop = () => {
|
||||
if (retryIntervalId !== null) {
|
||||
return;
|
||||
}
|
||||
retryIntervalId = window.setInterval(() => {
|
||||
if (receivedFirstHeight || signal.aborted) {
|
||||
stopRequestLoop();
|
||||
return;
|
||||
}
|
||||
requestHeight();
|
||||
}, 250);
|
||||
};
|
||||
|
||||
const handleMessage = (event) => {
|
||||
if (event.source !== iframe.contentWindow) {
|
||||
return;
|
||||
}
|
||||
if (event.origin !== targetOrigin) {
|
||||
return;
|
||||
}
|
||||
if (event.data?.type === 'bitvid-blog-resize') {
|
||||
applyHeight(event.data.height);
|
||||
receivedFirstHeight = true;
|
||||
stopRequestLoop();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage, { signal });
|
||||
|
||||
const requestHeight = () => {
|
||||
if (!iframe.contentWindow) {
|
||||
return;
|
||||
}
|
||||
iframe.contentWindow.postMessage(
|
||||
{ type: 'bitvid-blog-request-height' },
|
||||
targetOrigin,
|
||||
);
|
||||
};
|
||||
|
||||
let hasStarted = false;
|
||||
|
||||
const runInitialRequests = () => {
|
||||
if (hasStarted) {
|
||||
return;
|
||||
}
|
||||
hasStarted = true;
|
||||
requestHeight();
|
||||
startRequestLoop();
|
||||
if (signal.aborted) {
|
||||
stopRequestLoop();
|
||||
} else {
|
||||
signal.addEventListener('abort', stopRequestLoop, { once: true });
|
||||
}
|
||||
};
|
||||
|
||||
iframe.addEventListener('load', () => {
|
||||
receivedFirstHeight = false;
|
||||
stopRequestLoop();
|
||||
hasStarted = false;
|
||||
runInitialRequests();
|
||||
}, { signal });
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', runInitialRequests, {
|
||||
once: true,
|
||||
signal,
|
||||
});
|
||||
} else {
|
||||
runInitialRequests();
|
||||
}
|
||||
|
||||
window.addEventListener('pagehide', () => {
|
||||
abortController.abort();
|
||||
stopRequestLoop();
|
||||
}, { once: true });
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
Reference in new issue
Block a user