diff --git a/README.md b/README.md index 9b7a3f64..0e7dfd53 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,15 @@ and scripts for `style=` attributes, `element.style`, or `style.cssText` usage and will fail CI until offending markup is moved into the shared CSS/token system. +#### Design token guard + +`npm run lint:tokens` inspects JavaScript **and** `css/tailwind.source.css` +for raw `px`/`rem` measurements. Prefer design tokens, Tailwind `theme()` calls, +or existing utilities over hard-coded lengths. When unavoidable (for example +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. + Beacon builds inherit the same rule. `npm run build:beacon` now bundles `torrent/dist` and immediately re-runs the inline-style checker against the fresh output so third-party dependencies cannot sneak inline style mutations diff --git a/css/tailwind.source.css b/css/tailwind.source.css index 6d701786..9a72c5ca 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -9,7 +9,7 @@ font-size: 100%; } - @media (min-width: 1024px) { + @media (min-width: theme('screens.lg')) { html { font-size: 75%; } @@ -293,7 +293,7 @@ .popover__panel[data-variant="zap"] { background: var(--color-overlay-veil); color: var(--color-subtle); - border: 1px solid var(--color-border-translucent-stronger); + border: var(--border-width-hairline) solid var(--color-border-translucent-stronger); box-shadow: var(--shadow-popover-zap); backdrop-filter: blur(var(--popover-backdrop-blur)); -webkit-backdrop-filter: blur(var(--popover-backdrop-blur)); @@ -863,7 +863,7 @@ right var(--form-select-icon-hit-area) center; background-size: var(--form-select-icon-size) var(--form-select-icon-size), var(--form-select-icon-size) var(--form-select-icon-size), - 1px 60%; + var(--form-select-divider-width) 60%; background-repeat: no-repeat; } @@ -1205,12 +1205,12 @@ gap: var(--space-md); padding: var(--space-md-plus) var(--space-lg); border-radius: var(--radius-xl); - border: 1px solid var(--color-border-translucent); + border: var(--border-width-hairline) solid var(--color-border-translucent); background-color: var(--color-surface); box-shadow: var(--shadow-sm); } - @media (max-width: 480px) { + @media (max-width: theme('screens.xs')) { #playerModal .modal-nav .icon-button { width: var(--space-xl); height: var(--space-xl); @@ -1260,7 +1260,7 @@ #playerModal .description-panel { padding: var(--space-md-plus) var(--space-lg); border-radius: var(--radius-xl); - border: 1px solid var(--color-border-translucent); + border: var(--border-width-hairline) solid var(--color-border-translucent); background-color: var(--color-surface); box-shadow: var(--shadow-sm); } @@ -1284,9 +1284,9 @@ var(--color-overlay-panel-gradient-start) 0%, var(--color-overlay-panel-gradient-end) 100% ); - border-bottom: 1px solid var(--color-border-translucent); - backdrop-filter: blur(6px); - -webkit-backdrop-filter: blur(6px); + border-bottom: var(--border-width-hairline) solid var(--color-border-translucent); + backdrop-filter: blur(var(--modal-nav-backdrop-blur)); + -webkit-backdrop-filter: blur(var(--modal-nav-backdrop-blur)); } #playerModal .modal-nav__bar { @@ -1307,7 +1307,7 @@ width: var(--icon-size-lg); height: var(--icon-size-lg); background: var(--color-surface-tint-subtle); - border: 1px solid var(--color-border-translucent-medium); + border: var(--border-width-hairline) solid var(--color-border-translucent-medium); color: var(--color-text-muted); } @@ -1330,7 +1330,7 @@ @apply grid gap-6 lg:gap-8; } - @media (min-width: 1024px) { + @media (min-width: theme('screens.lg')) { .video-modal__layout { grid-template-columns: minmax(0, var(--video-modal-primary-fr)) @@ -1342,7 +1342,7 @@ @apply grid gap-6; } - @media (min-width: 1024px) { + @media (min-width: theme('screens.lg')) { .revert-modal__layout { grid-template-columns: minmax(var(--revert-modal-sidebar-min), var(--revert-modal-sidebar-width)) @@ -1364,7 +1364,7 @@ padding-block: clamp(var(--space-xs), 2.5vw, var(--space-md-plus)); } - @media (min-width: 1024px) { + @media (min-width: theme('screens.lg')) { .video-modal__video { position: sticky; top: calc(var(--space-2xl) + var(--space-xl)); @@ -1398,7 +1398,8 @@ } .video-modal__action-button { - @apply inline-flex h-11 min-w-[2.75rem] items-center justify-center gap-2 rounded-full border border-border border-opacity-30 bg-panel px-3 text-sm font-semibold text-text transition duration-base ease-out hover:bg-panel-hover; + @apply inline-flex h-11 items-center justify-center gap-2 rounded-full border border-border border-opacity-30 bg-panel px-3 text-sm font-semibold text-text transition duration-base ease-out hover:bg-panel-hover; + min-width: var(--video-modal-action-min-width); } .video-modal__action-label { @@ -1444,7 +1445,7 @@ .torrent-clipboard-textarea { position: fixed; top: 0; - left: -9999px; + left: var(--offscreen-offset); opacity: 0; width: 0; height: 0; @@ -1648,7 +1649,7 @@ } .watch-history-card__meta { - @apply flex min-w-0 flex-col justify-between gap-4 sm:min-w-[14rem] sm:flex-[0_0_16rem]; + @apply flex min-w-0 flex-col justify-between gap-4 sm:min-w-56 sm:flex-none sm:basis-64; } .watch-history-card__watched { @@ -2006,7 +2007,7 @@ body.iframe-form #status { background: var(--color-surface); border-radius: var(--radius-sm); - box-shadow: inset 0 0 0 1px var(--color-border-translucent); + box-shadow: inset 0 0 0 var(--border-width-hairline) var(--color-border-translucent); color: var(--color-subtle-text); min-height: calc(var(--space-2xl) + var(--space-xl)); padding: var(--space-sm); @@ -2030,7 +2031,7 @@ color: var(--color-warning-strong); } - @media (max-width: 640px) { + @media (max-width: theme('screens.sm')) { body.iframe-form { margin: var(--space-md); } @@ -2077,7 +2078,7 @@ var(--space-sidebar-shell-block-end); width: 100%; border-radius: var(--radius-sidebar-shell); - border: 1px solid var(--color-sidebar-border-strong); + border: var(--border-width-hairline) solid var(--color-sidebar-border-strong); background: radial-gradient( 140% 140% at 15% 10%, @@ -2154,7 +2155,7 @@ width: var(--sidebar-control-size); height: var(--sidebar-control-size); border-radius: var(--radius-full); - border: 1px solid var(--color-border-translucent-medium); + border: var(--border-width-hairline) solid var(--color-border-translucent-medium); background: var(--color-surface-tint-subtle); color: inherit; cursor: pointer; @@ -2206,14 +2207,14 @@ text-decoration: none; width: 100%; position: relative; - border: 1px solid var(--color-sidebar-border); + border: var(--border-width-hairline) solid var(--color-sidebar-border); background: linear-gradient( 155deg, color-mix(in srgb, var(--color-sidebar-surface) 72%, transparent) 0%, color-mix(in srgb, var(--color-sidebar-surface-alt) 48%, transparent) 100% ); - box-shadow: inset 0 0 0 0.5px var(--color-sidebar-border); + box-shadow: inset 0 0 0 var(--border-width-fine) var(--color-sidebar-border); transform: translateX(var(--sidebar-nav-translate-x)); transition: color 180ms ease, border-color 200ms ease, box-shadow 220ms ease, transform 220ms ease; @@ -2229,7 +2230,7 @@ .sidebar-nav-link::after { content: ""; position: absolute; - inset: -1px; + inset: calc(var(--border-width-hairline) * -1); border-radius: inherit; pointer-events: none; transition: opacity 220ms ease, transform 240ms ease; @@ -2265,7 +2266,7 @@ --sidebar-nav-translate-x: var(--space-sidebar-nav-translate); color: var(--color-white); border-color: var(--color-sidebar-hover-strong); - box-shadow: 0 0 0 1px var(--color-sidebar-hover-strong), + box-shadow: 0 0 0 var(--border-width-hairline) var(--color-sidebar-hover-strong), var(--shadow-sidebar-accent); } @@ -2317,7 +2318,7 @@ padding: var(--space-sidebar-trigger-padding-block) var(--space-md); min-height: var(--sidebar-control-size); border-radius: var(--radius-sidebar-nav); - border: 1px solid var(--color-sidebar-border); + border: var(--border-width-hairline) solid var(--color-sidebar-border); background: linear-gradient( 155deg, @@ -2336,7 +2337,7 @@ saturate(var(--sidebar-trigger-backdrop-saturation)); -webkit-backdrop-filter: blur(var(--sidebar-backdrop-blur)) saturate(var(--sidebar-trigger-backdrop-saturation)); - box-shadow: inset 0 0 0 0.5px var(--color-sidebar-border), + box-shadow: inset 0 0 0 var(--border-width-fine) var(--color-sidebar-border), var(--shadow-sidebar-trigger); transition: border-color 200ms ease, color 200ms ease, box-shadow 220ms ease, background 220ms ease; @@ -2345,7 +2346,7 @@ .sidebar-dropup-trigger::before { content: ""; position: absolute; - inset: -1px; + inset: calc(var(--border-width-hairline) * -1); border-radius: inherit; pointer-events: none; background: linear-gradient( @@ -2363,7 +2364,7 @@ .sidebar-dropup-trigger::after { content: ""; position: absolute; - inset: -1px; + inset: calc(var(--border-width-hairline) * -1); border-radius: inherit; pointer-events: none; background: linear-gradient(110deg, var(--color-sidebar-hover) 0%, transparent 80%); @@ -2377,7 +2378,7 @@ .sidebar-dropup-trigger:focus-visible { color: var(--color-white); border-color: var(--color-sidebar-hover-strong); - box-shadow: 0 0 0 1px var(--color-sidebar-hover-strong), + box-shadow: 0 0 0 var(--border-width-hairline) var(--color-sidebar-hover-strong), var(--shadow-sidebar-dropup); } @@ -2420,14 +2421,14 @@ right: 0; bottom: calc(100% + var(--space-sidebar-popover-offset)); border-radius: var(--radius-xl); - border: 1px solid var(--color-sidebar-border-strong); + border: var(--border-width-hairline) solid var(--color-sidebar-border-strong); background: linear-gradient( 160deg, color-mix(in srgb, var(--color-sidebar-surface) 88%, transparent) 0%, color-mix(in srgb, var(--color-sidebar-surface-alt) 68%, transparent) 100% ); - box-shadow: inset 0 0 0 0.5px var(--color-sidebar-border), + box-shadow: inset 0 0 0 var(--border-width-fine) var(--color-sidebar-border), var(--shadow-sidebar-panel); padding: var(--space-sidebar-popover-padding-block) var(--space-sidebar-popover-padding-inline); @@ -2470,7 +2471,7 @@ #sidebar.sidebar-open { box-shadow: var(--shadow-sidebar-shell), - 0 0 0 1px var(--color-sidebar-border-strong); + 0 0 0 var(--border-width-hairline) var(--color-sidebar-border-strong); } #sidebarOverlay { @@ -2507,7 +2508,9 @@ white-space: nowrap; } - @media (max-width: 767px) { + @media ( + max-width: calc(theme('screens.md') - theme('borderWidth.DEFAULT')) + ) { #sidebar { transform: translate3d(-100%, 0, 0); width: min( @@ -2525,7 +2528,7 @@ } } - @media (min-width: 768px) { + @media (min-width: theme('screens.md')) { .sidebar-panel__header { display: flex; } @@ -2674,7 +2677,7 @@ @keyframes card-motion-enter { from { opacity: 0; - transform: translateY(0.5rem); + transform: translateY(var(--motion-card-enter-translate)); } to { @@ -2706,7 +2709,7 @@ @keyframes fadeIn { from { opacity: 0; - transform: translateY(1.25rem); + transform: translateY(var(--motion-fade-in-translate)); } to { @@ -2723,7 +2726,7 @@ } @layer utilities { - @media (max-width: 25rem) { + @media (max-width: theme('screens.compact')) { .u-max-400-flex-wrap { flex-wrap: wrap; } @@ -2762,7 +2765,7 @@ } } -@media (max-width: 40rem) { +@media (max-width: theme('screens.sm')) { .bv-grid-video { gap: var(--space-md); grid-template-columns: 1fr; @@ -2854,7 +2857,8 @@ @layer components { .ui-grid-row { - border-bottom: 1px solid var(--color-border-subtle, rgb(209 213 219)); + border-bottom: var(--border-width-hairline) + solid var(--color-border-subtle, rgb(209 213 219)); } .status-line { @@ -2883,7 +2887,7 @@ font-size: var(--blog-font-size-root); } - @media (max-width: 768px) { + @media (max-width: theme('screens.md')) { .blog-html { font-size: var(--font-size-lg); } @@ -2974,7 +2978,7 @@ color: var(--color-blog-gray-220); } - @media (max-width: 768px) { + @media (max-width: theme('screens.md')) { .blog-app-root .header .external-link { margin-bottom: var(--space-md); margin-right: var(--blog-space-inline-lg); @@ -3115,13 +3119,13 @@ border-bottom: var(--space-xs) solid var(--color-blog-neutral-100); } - @media (max-width: 768px) { + @media (max-width: theme('screens.md')) { .blog-app-root .top-notes { display: block; } } - @media (max-width: 768px) { + @media (max-width: theme('screens.md')) { .blog-app-root .top-notes { padding-bottom: 0; } @@ -3132,7 +3136,7 @@ box-sizing: border-box; } - @media (max-width: 768px) { + @media (max-width: theme('screens.md')) { .blog-app-root .top-notes .note { margin-bottom: var(--space-xl); } @@ -3144,7 +3148,7 @@ .blog-app-root .top-notes .note img { width: 100%; - height: 200px; + height: var(--blog-top-note-image-height); object-fit: cover; margin-bottom: var(--space-xs); border-radius: var(--radius-sm); @@ -3201,7 +3205,7 @@ color: var(--color-blog-gray-220); } - @media (max-width: 768px) { + @media (max-width: theme('screens.md')) { .blog-app-root .short-notes [data-carousel-control] { display: none; } @@ -3285,7 +3289,7 @@ margin-top: 0; } - @media (max-width: 768px) { + @media (max-width: theme('screens.md')) { .blog-app-root h1 { font-size: var(--blog-font-size-h1-mobile); } @@ -3388,7 +3392,7 @@ font-family: var(--ztr-font); font-size: var(--ztr-font-size); color: var(--ztr-text-color); - border: 1px solid transparent; + border: var(--border-width-hairline) solid transparent; word-wrap: break-word; margin-top: 4em; line-height: 1.2em; @@ -3490,7 +3494,7 @@ line-break: loose; word-break: break-word; overflow: hidden; - max-width: 750px; + max-width: var(--layout-blog-max-width); } .blog-app-root .ztr-comment-text p { @@ -3615,7 +3619,7 @@ line-height: 1.4; color: var(--color-blog-gray-800); background-color: var(--color-blog-neutral-50); - border: 1px solid var(--color-blog-gray-220); + border: var(--border-width-hairline) solid var(--color-blog-gray-220); outline: none; width: calc(100% - 1.5em); padding: 0.7em; @@ -3778,8 +3782,8 @@ .blog-app-root .loader.svelte-181fgen { display: inline-block; - width: 92px; - height: 92px; + width: var(--blog-loader-dimension-lg); + height: var(--blog-loader-dimension-lg); opacity: 0; animation: svelte-181fgen-fade-in 0.5s 0.4s forwards, @@ -3789,11 +3793,11 @@ .blog-app-root .loader.svelte-181fgen:after { content: " "; display: block; - width: 64px; - height: 64px; + width: var(--blog-loader-dimension-md); + height: var(--blog-loader-dimension-md); margin: var(--space-xs); border-radius: 50%; - border: 6px solid var(--color-blog-gray-275); + border: var(--blog-loader-border-width) solid var(--color-blog-gray-275); border-color: var(--color-blog-gray-275) transparent var(--color-blog-gray-275) transparent; animation: svelte-181fgen-loader-animation 2s linear infinite; } @@ -3815,7 +3819,7 @@ } .dark .blog-app-root .loader.svelte-181fgen:after { - border: 6px solid var(--color-card); + border: var(--blog-loader-border-width) solid var(--color-card); border-color: var(--color-card) transparent var(--color-card) transparent; } @@ -3836,7 +3840,7 @@ content: ""; } - @media (max-width: 768px) { + @media (max-width: theme('screens.md')) { .blog-body #theme-toggle.svelte-bxrhh + label.svelte-bxrhh { right: var(--space-md); } @@ -3848,7 +3852,7 @@ .blog-body #theme-toggle.svelte-bxrhh:checked + label.svelte-bxrhh { background-color: transparent; - box-shadow: inset -6px -6px 1px 1px var(--color-card); + box-shadow: var(--blog-theme-toggle-shadow); } .blog-app-root .ztr-nowrap { diff --git a/css/tokens.css b/css/tokens.css index b131b5f5..34a27d04 100644 --- a/css/tokens.css +++ b/css/tokens.css @@ -136,6 +136,7 @@ --blur-modal-backdrop: 28px; --blur-modal-alert: 14px; --blur-thumbnail-blur: 18px; + --modal-nav-backdrop-blur: 6px; --border-default: var(--color-border); --border-subtle: var(--color-border-subtle); @@ -146,6 +147,8 @@ --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-strong); @@ -349,6 +352,13 @@ --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; @@ -392,6 +402,8 @@ --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); @@ -399,6 +411,7 @@ --form-textarea-min-height: 8rem; --scrollbar-thin-width: 0.4rem; --modal-progress-height: calc(var(--space-3xs) * 3); + --video-modal-action-min-width: 2.75rem; --outline-thick-width: 0.1875rem; --icon-button-ring-width: var(--outline-thick-width); @@ -407,8 +420,16 @@ --form-select-icon-gap: var(--space-xs-snug); --form-select-icon-hit-area: var(--space-xl-plus); --form-select-icon-size: 0.6rem; + --form-select-divider-width: var(--border-width-hairline); --form-checkbox-checkmark-offset: calc(var(--space-3xs) * -1); + /* Utility measurements */ + --offscreen-offset: -9999px; + + /* Motion */ + --motion-card-enter-translate: var(--space-xs); + --motion-fade-in-translate: var(--space-md-plus); + /* Shadows */ --shadow-sm: 0 0.0625rem 0.125rem 0 rgb(0 0 0 / 5%); --shadow-md: 0 0.25rem 0.375rem -0.0625rem rgb(0 0 0 / 10%); diff --git a/scripts/check-design-tokens.mjs b/scripts/check-design-tokens.mjs index 5fccf938..d109defd 100755 --- a/scripts/check-design-tokens.mjs +++ b/scripts/check-design-tokens.mjs @@ -10,11 +10,31 @@ const __dirname = path.dirname(__filename); const repoRoot = path.resolve(__dirname, ".."); const TARGET_DIRECTORIES = ["js/ui"]; -const TARGET_FILES = ["js/channelProfile.js"]; +const TARGET_FILES = ["js/channelProfile.js", "css/tailwind.source.css"]; const IGNORED_EXTENSIONS = new Set([".min.js", ".map"]); -const ALLOWED_FILES = new Set(["js/ui/components/RevertModal.js"]); +const ALLOWED_FILES = new Set([ + "js/ui/components/RevertModal.js", + "css/tokens.css", +]); const LENGTH_PATTERN = /\d+(?:\.\d+)?(?:px|rem)\b/gim; +const GLOBAL_VALUE_ALLOWLIST = [ + (value) => /^0+(?:\.0+)?(?:px|rem)$/i.test(value), +]; + +const VALUE_ALLOWLIST = new Map([ + [ + "css/tailwind.source.css", + [ + // Hairline borders and reset styles that intentionally rely on raw pixels. + (value, snippet) => + /^0?\.5px$/i.test(value) && /box-shadow/i.test(snippet), + (value, snippet) => + /^1px$/i.test(value) && /(border|box-shadow|outline)/i.test(snippet), + ], + ], +]); + const MARKUP_EXTENSIONS = new Set([".html", ".md", ".mdx", ".markdown", ".njk", ".nunjucks"]); const MARKUP_IGNORED_DIRECTORIES = new Set(["node_modules", ".git"]); const CLASS_ATTRIBUTE_PATTERN = /class\s*=\s*(["'])(.*?)\1/gis; @@ -25,6 +45,40 @@ function toPosix(relPath) { return relPath.split(path.sep).join("/"); } +function getAllowlistEntries(filePath) { + const entries = [...GLOBAL_VALUE_ALLOWLIST]; + const relPath = toPosix(path.relative(repoRoot, filePath)); + const fileSpecific = VALUE_ALLOWLIST.get(relPath); + if (fileSpecific) { + entries.push(...fileSpecific); + } + return entries; +} + +function isValueAllowed(filePath, value, snippet) { + const entries = getAllowlistEntries(filePath); + for (const entry of entries) { + if (entry instanceof RegExp) { + if (entry.test(value)) { + return true; + } + continue; + } + if (typeof entry === "function") { + if (entry(value, snippet, filePath)) { + return true; + } + continue; + } + if (typeof entry === "string") { + if (entry.toLowerCase() === value.toLowerCase()) { + return true; + } + } + } + return false; +} + async function collectScriptFiles() { const results = new Set(); @@ -174,14 +228,15 @@ function collectViolations(filePath, content) { continue; } const value = match[0]; - if (/^0+(?:\.0+)?(?:px|rem)$/i.test(value)) { + const snippet = getLineSnippet(content, index); + if (isValueAllowed(filePath, value, snippet)) { continue; } violations.push({ file: toPosix(path.relative(repoRoot, filePath)), line: getLineNumber(lineBreaks, index), value, - snippet: getLineSnippet(content, index), + snippet, }); } return violations; diff --git a/tailwind.config.cjs b/tailwind.config.cjs index 5313ffe3..a3b9dae0 100644 --- a/tailwind.config.cjs +++ b/tailwind.config.cjs @@ -13,6 +13,10 @@ module.exports = { safelist: ["bv-logo__background"], theme: { extend: { + screens: { + xs: "30rem", + compact: "25rem", + }, colors: { page: "var(--color-page)", "page-alt": "var(--color-page-alt)",