diff --git a/app/assets/stylesheets/_base.scss b/app/assets/stylesheets/_base.scss new file mode 100644 index 00000000..f98b0451 --- /dev/null +++ b/app/assets/stylesheets/_base.scss @@ -0,0 +1,37 @@ +body { + text-wrap: pretty; +} + +// Keep the footer below the fold on short pages. +body > main > .container { + min-height: 60vh; +} + +// Page titles rendered by the shared page header / article headers. +article > header > h1, +article > header > h2 { + font-weight: 600; + margin: 1rem 0 1.5rem; +} + +// Prose links: keep underline (a11y) with a comfortable offset; nav/cards opt out. +a { + text-underline-offset: .15em; +} + +ol.breadcrumb { + font-size: .86rem; + padding: .5rem 0; +} + +label.required::after { + content: '*'; + color: var(--bs-danger); + margin-inline-start: .125rem; +} + +// Code, MAC/IP addresses, hex values keep LTR order even in RTL locales. +code, kbd, pre, samp, .text-data { + direction: ltr; + unicode-bidi: embed; +} diff --git a/app/assets/stylesheets/_fonts.scss b/app/assets/stylesheets/_fonts.scss new file mode 100644 index 00000000..4c99d985 --- /dev/null +++ b/app/assets/stylesheets/_fonts.scss @@ -0,0 +1,44 @@ +// Self-hosted IBM Plex. The woff2 files live in public/fonts/ and are copied +// verbatim from the @fontsource packages by `yarn build:fonts`. +// +// Self-hosted rather than fetched from fonts.googleapis.com: the CDN import +// this replaces made every visitor's browser announce itself to Google before +// the page could paint, and it is the same argument that moved the legacy logo +// off a maintainer's personal CDN in d60729a. +// +// Subsets: latin covers English, latin-ext the accented names in the partner +// wall, cyrillic the Russian copy. Chinese falls back to the system stack -- +// IBM Plex Sans has no CJK glyphs, and the CJK families that do are megabytes. + +$plex-subsets: ( + 'latin': (U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, + U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD), + 'latin-ext': (U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, + U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, + U+2113, U+2C60-2C7F, U+A720-A7FF), + 'cyrillic': (U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116), +); + +@each $subset, $range in $plex-subsets { + @each $weight in (400, 500, 600, 700) { + @font-face { + font-family: 'IBM Plex Sans'; + font-style: normal; + font-display: swap; + font-weight: $weight; + src: url('/fonts/ibm-plex-sans-#{$subset}-#{$weight}-normal.woff2') format('woff2'); + unicode-range: $range; + } + } + + @each $weight in (400, 600) { + @font-face { + font-family: 'IBM Plex Mono'; + font-style: normal; + font-display: swap; + font-weight: $weight; + src: url('/fonts/ibm-plex-mono-#{$subset}-#{$weight}-normal.woff2') format('woff2'); + unicode-range: $range; + } + } +} diff --git a/app/assets/stylesheets/_utilities.scss b/app/assets/stylesheets/_utilities.scss new file mode 100644 index 00000000..4f11ef13 --- /dev/null +++ b/app/assets/stylesheets/_utilities.scss @@ -0,0 +1,36 @@ +.x-small { + font-size: 0.8125rem; +} + +.img-zoom:hover { + cursor: zoom-in; +} + +.ratio { + overflow: auto; +} + +.offcanvas { + --bs-offcanvas-width: 30vw; + min-width: 25rem; +} + +// Responsive breakpoint debugger (enabled via body.debug from a fixed IP). +@media (max-width: 575.98px) { body.debug:before { content: 'xs' } } +@media (min-width: 576px) and (max-width: 767.98px) { body.debug:before { content: 'sm' } } +@media (min-width: 768px) and (max-width: 991.98px) { body.debug:before { content: 'md' } } +@media (min-width: 992px) and (max-width: 1199.98px) { body.debug:before { content: 'lg' } } +@media (min-width: 1200px) and (max-width: 1399.98px) { body.debug:before { content: 'xl' } } +@media (min-width: 1400px) { body.debug:before { content: 'xxl' } } +body.debug:before { + color: #4c60d811; + display: block; + font-size: 10rem; + font-variant: all-small-caps; + font-weight: 700; + line-height: 1; + position: fixed; + right: 3rem; + top: 3rem; + z-index: -1; +} diff --git a/app/assets/stylesheets/_variables.scss b/app/assets/stylesheets/_variables.scss new file mode 100644 index 00000000..ed760ef6 --- /dev/null +++ b/app/assets/stylesheets/_variables.scss @@ -0,0 +1,88 @@ +// Theme: "engineering workbench" — light body, dark ink anchor sections, brand indigo. +// These overrides MUST load after bootstrap/scss/functions and before the main bootstrap import. + +// --- Brand palette --- +$indigo: #4c60d8; // brand color (5.3:1 on white — AA for text) +$primary: $indigo; +// Bootstrap emits --bs-blue from $blue, and the pre-relaunch rules in +// pages/_legacy.scss colour headings with var(--bs-blue). Left at Bootstrap's +// default those rules would render #0d6efd -- the same "two blues" split this +// file exists to close, just from the other side. +$blue: $indigo; +$ink: #0f1422; // navbar, hero, stats/CTA bands, footer +$ink-2: #1a2236; // raised surfaces on ink +$accent: #ffb454; // warm amber: Labs, live-dot, highlights; never text-on-white +$success: #33a867; // matches stage-done.svg +$danger: #d6453d; +$warning: #e8a13c; +$info: #3e8ed0; + +// --- Neutrals (cool, indigo-tinted) --- +$body-bg: #ffffff; +$body-color: #1c2333; +$body-secondary-color: #5a6377; +$body-tertiary-bg: #f4f6fa; +$border-color: #e3e7f0; + +// --- Typography --- +$font-family-sans-serif: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', sans-serif; +$font-family-monospace: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace; +$font-size-base: 1rem; +$line-height-base: 1.6; +$headings-font-weight: 600; // 700 reserved for hero/display +$display-font-weight: 700; +$h1-font-size: 2.25rem; +$h2-font-size: 1.75rem; +$h3-font-size: 1.375rem; +$code-color: #b23a78; + +// --- Spacing (Bootstrap defaults + 6/7 for section rhythm: py-6/py-7) --- +$spacer: 1rem; +$spacers: ( + 0: 0, + 1: $spacer * .25, + 2: $spacer * .5, + 3: $spacer, + 4: $spacer * 1.5, + 5: $spacer * 3, + 6: $spacer * 4.5, + 7: $spacer * 6, +); + +// --- Shape --- +$border-radius: .375rem; +$border-radius-sm: .25rem; +$border-radius-lg: .625rem; +$border-radius-xl: 1rem; +$box-shadow-sm: 0 1px 2px rgba(15, 20, 34, .06); +$box-shadow: 0 8px 24px rgba(15, 20, 34, .10); +$card-border-color: $border-color; +$card-cap-bg: transparent; + +// --- Links / buttons / focus --- +$link-color: $indigo; +$link-decoration: underline; +$btn-font-weight: 600; +$btn-padding-x: 1.125rem; +$btn-padding-y: .5rem; +$btn-padding-x-lg: 1.5rem; +$btn-padding-y-lg: .75rem; +$focus-ring-color: rgba($indigo, .3); + +// --- Components --- +$carousel-transition-duration: .3s; // carried over; the Open Wall slideshow relies on it +$enable-dark-mode: true; // needed for scoped data-bs-theme="dark" sections +$navbar-dark-color: rgba(#fff, .78); +$navbar-dark-hover-color: #fff; +$badge-font-weight: 600; +$table-cell-padding-y: .6rem; + +// --- Project status stages (NEQ/R&D/HLP/WIP/MVP/DONE) --- +$stage-colors: ( + 'neq': #8a93a6, + 'rnd': #7a5cd6, + 'hlp': #3e8ed0, + 'wip': #0fa3a3, + 'mvp': #e8a13c, + 'done': #33a867, +); diff --git a/app/assets/stylesheets/application.bootstrap.scss b/app/assets/stylesheets/application.bootstrap.scss index cdc91b79..35c89cd9 100644 --- a/app/assets/stylesheets/application.bootstrap.scss +++ b/app/assets/stylesheets/application.bootstrap.scss @@ -1,193 +1,28 @@ -@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono&family=IBM+Plex+Sans:wght@400;700&display=swap'); -@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&display=swap'); -@import url('https://cdn.jsdelivr.net/npm/bootstrap-icons@1.8.3/font/bootstrap-icons.css'); - +// Entry point for the site stylesheet. +// +// The import ORDER is the point of this file. Our theme variables have to be +// read after Bootstrap's functions (they use rgba(), shade-color() and friends) +// and before Bootstrap itself, because Bootstrap's own variables are declared +// !default and the first definition wins. Loading them afterwards -- which is +// what the single flat stylesheet used to do -- left $primary at Bootstrap's +// #0d6efd while :root said --bs-blue: #4c60d8, so buttons and links rendered a +// different blue from everything else on the page. +@import 'fonts'; +@import 'bootstrap/scss/functions'; +@import 'variables'; @import 'bootstrap/scss/bootstrap'; -@import 'bootstrap-icons/font/bootstrap-icons'; -//@import 'rails_bootstrap_forms'; - -/* debugging start */ -@media (max-width: 575.98px) { body.debug:before {content: "xs"} } -@media (min-width: 576px) and (max-width: 767.98px) { body.debug:before {content: "sm"} } -@media (min-width: 768px) and (max-width: 991.98px) { body.debug:before {content: "md"} } -@media (min-width: 992px) and (max-width: 1199.98px) { body.debug:before {content: "lg"} } -@media (min-width: 1200px) and (max-width: 1399.98px) { body.debug:before {content: "xl"} } -@media (min-width: 1400px) { body.debug:before {content: "xxl"} } -body.debug:before {color: #4c60d811;display: block;font-size: 10rem;font-variant: all-small-caps;font-weight: 700;line-height: 1;position: fixed;right: 3rem;top: 3rem;z-index: -1} -.offcanvas {--bs-offcanvas-width: 30vw;min-width: 25rem;} -/* debugging end */ - -:root { - --bs-body-font-family: "IBM Plex Sans", sans-serif; - --bs-font-monospace: "IBM Plex Mono", monospace; - --bs-blue: #4c60d8; -} - -$carousel-transition-duration: .3s; - -.ratio { - overflow: auto; -} - -.col > h3 { - color: var(--bs-blue); - font-size: 1.3rem; - margin-bottom: 1.0rem; - margin-top: 1.5rem; -} - -.x-small { - font-size: 0.8125rem; -} - -body > main > .container { - min-height: 60vh; - - > article > header > h2 { - font-weight: 700; - color: var(--bs-blue); - margin: 1rem 0 1.5rem; - } -} - -body > nav.navbar, -body > header { - background: var(--bs-blue); - color: #ffffff; -} - -body.admin > nav.navbar, -body.admin > header { - background: #B00100; - color: #ffffff; -} - -i.bi-github { font-size: 2.3rem; line-height: 1; } - -.tg img { height: 2.5rem; } - -html:not([lang="ru"]) article.introduction .col.logo.ru { display: none; } - -article.calculator { - .form-floating > label { - padding: 0.5rem 0.25rem; - } - - .form-control:not(:placeholder-shown) { - padding-bottom: 0.5rem; - } - - .col:not(:first-child) { - text-align: right; - } - - input, select { - background: #f7f7f7; - font-family: 'Share Tech Mono', monospace; - font-size: 1.4rem; - font-weight: 400; - } - - .dec, .hex { - input, select { font-size: 2.0rem; text-align: right; } - } - input.start[readonly] { background-color: #4c60d8; color: white; } - input[readonly] { border: none; color: var(--bs-blue); outline: none; } - - #mtdparts { font-size: 1.75rem; word-break: break-all; } - - input.border-color { border-width: 3px; } - - #offset { border-color: #000000; } - - // red,orange,yellow,springgreen,green,turquoise,cyan,ocean,blue,violet,magenta,raspberry - - .progress { height: 2rem; - .bg-color-0 { background: #000000 } - .bg-color-1 { background: #ff0000 } - .bg-color-2 { background: #ffff00 } - .bg-color-3 { background: #ff7700 } - .bg-color-4 { background: #007700 } - .bg-color-5 { background: #261 } - .bg-color-6 { background: #226 } - .bg-color-7 { background: #a13 } - .bg-color-8 { background: #b72 } - } -} - -article.hardware { - > ul.nav { - --bs-nav-link-padding-x: 0.3rem; - } -} - -.card-body { - position: relative; -} - -.social { - position: absolute; - right: 0.25rem; - top: -0.75rem; - text-align: right; - - a { - background: white; - border-radius: 50%; - display: inline-flex; - padding: 2px; - - img { - height: 1.25rem; - vertical-align: text-bottom; - } - } -} - -ol.breadcrumb { font-size: 0.86rem; padding: 0.5rem 0; } - -.socs { - .stage { text-align: center; } - .url { text-align: center; } - .address { text-align: right; } - - .stage img { height: 1.25rem; } - .icon { max-height: 30px; } - // filter: invert(38%) sepia(63%) saturate(5330%) hue-rotate(225deg) brightness(92%) contrast(83%); - //.stage.neq { background-color: #00000033 } - //.stage.hlp { background-color: #ffffff33 } - //.stage.rnd { background-color: #0000ff33 } - //.stage.wip { background-color: #00ffff33 } - //.stage.mvp { background-color: #ffff0033 } - //.stage.done { background-color: #00ff0033 } - - dd a.anchor { display: none; } - dd:hover a.anchor { display: inline; } -} - -label.required:after { - content:"*"; - color: red; - margin-left: 0.125rem; -} - -.carousel-item .carousel-caption { - background-color: #1e1e1e66; - color: #f1f1f166; - a { color: #f1f1f166; } -} - -.carousel-item.active:hover .carousel-caption { - background-color: #1e1e1eff; - color: #f1f1f1ff; - a { color: #ffcc00; } -} - -table.socs tr.featured { - background-color: #4c60d822; -} +// bootstrap-icons resolves its woff2 against this. The variable is +// $bootstrap-icons-font-dir; $bi-font-path is the name from a different major +// version and silently does nothing. +$bootstrap-icons-font-dir: '/fonts'; +@import 'bootstrap-icons/font/bootstrap-icons'; -.img-zoom:hover { - cursor: zoom-in; -} +@import 'base'; +@import 'components/navbar'; +@import 'components/cards'; +@import 'pages/hardware'; +@import 'pages/calculator'; +@import 'pages/openwall'; +@import 'pages/legacy'; +@import 'utilities'; diff --git a/app/assets/stylesheets/components/_cards.scss b/app/assets/stylesheets/components/_cards.scss new file mode 100644 index 00000000..29ea4c04 --- /dev/null +++ b/app/assets/stylesheets/components/_cards.scss @@ -0,0 +1,23 @@ +// Team member cards: the social icons float over the top edge of the card. +.card-body { + position: relative; +} + +.social { + position: absolute; + right: .25rem; + top: -.75rem; + text-align: right; + + a { + background: white; + border-radius: 50%; + display: inline-flex; + padding: 2px; + + img { + height: 1.25rem; + vertical-align: text-bottom; + } + } +} diff --git a/app/assets/stylesheets/components/_navbar.scss b/app/assets/stylesheets/components/_navbar.scss new file mode 100644 index 00000000..29f54e9c --- /dev/null +++ b/app/assets/stylesheets/components/_navbar.scss @@ -0,0 +1,33 @@ +body > nav.navbar, +body > header { + background: $ink; + color: #fff; + border-bottom: 1px solid rgba(255, 255, 255, .08); + + .nav-link { + text-decoration: none; + } + + .dropdown-menu { + --bs-dropdown-link-active-bg: #{$indigo}; + + a { + text-decoration: none; + } + } +} + +body.admin > nav.navbar, +body.admin > header { + background: #b00100; + color: #fff; +} + +i.bi-github { + font-size: 2.3rem; + line-height: 1; +} + +.tg img { + height: 2.5rem; +} diff --git a/app/assets/stylesheets/pages/_calculator.scss b/app/assets/stylesheets/pages/_calculator.scss new file mode 100644 index 00000000..bba8dccc --- /dev/null +++ b/app/assets/stylesheets/pages/_calculator.scss @@ -0,0 +1,46 @@ +// Web tools: partition calculator, QR generator, timers (existing pages). +article.calculator { + .form-floating > label { + padding: 0.5rem 0.25rem; + } + + .form-control:not(:placeholder-shown) { + padding-bottom: 0.5rem; + } + + .col:not(:first-child) { + text-align: right; + } + + input, select { + background: #f7f7f7; + font-family: $font-family-monospace; + font-size: 1.4rem; + font-weight: 400; + } + + .dec, .hex { + input, select { font-size: 2.0rem; text-align: right; } + } + + input.start[readonly] { background-color: $indigo; color: white; } + input[readonly] { border: none; color: $indigo; outline: none; } + + #mtdparts { font-size: 1.75rem; word-break: break-all; } + + input.border-color { border-width: 3px; } + + #offset { border-color: #000000; } + + .progress { height: 2rem; + .bg-color-0 { background: #000000 } + .bg-color-1 { background: #ff0000 } + .bg-color-2 { background: #ffff00 } + .bg-color-3 { background: #ff7700 } + .bg-color-4 { background: #007700 } + .bg-color-5 { background: #261 } + .bg-color-6 { background: #226 } + .bg-color-7 { background: #a13 } + .bg-color-8 { background: #b72 } + } +} diff --git a/app/assets/stylesheets/pages/_hardware.scss b/app/assets/stylesheets/pages/_hardware.scss new file mode 100644 index 00000000..0e5dd433 --- /dev/null +++ b/app/assets/stylesheets/pages/_hardware.scss @@ -0,0 +1,23 @@ +// Supported-hardware browser. The table.binaries block that used to live here +// went with /binaries itself in 2cf8bc9; do not reintroduce it. +article.hardware { + > ul.nav { + --bs-nav-link-padding-x: 0.3rem; + } +} + +.socs { + .stage { text-align: center; } + .url { text-align: center; } + .address { text-align: right; } + + .stage img { height: 1.25rem; } + .icon { max-height: 30px; } + + dd a.anchor { display: none; } + dd:hover a.anchor { display: inline; } +} + +table.socs tr.featured { + background-color: rgba($indigo, .13); +} diff --git a/app/assets/stylesheets/pages/_legacy.scss b/app/assets/stylesheets/pages/_legacy.scss new file mode 100644 index 00000000..e9322e29 --- /dev/null +++ b/app/assets/stylesheets/pages/_legacy.scss @@ -0,0 +1,39 @@ +// Rules belonging to the pre-relaunch pages. They are kept intact, and separate, +// so the cutover that removes those pages can delete this file rather than pick +// rules out of the ones above. + +// Section headings inside a grid column. Fifteen existing pages rely on this -- +// our-team, our-channels, the calculators, majestic-endpoints and the rest -- +// so it cannot go until they do. The new pages do not use it: their headings +// take their colour from the body, not the brand. +.col > h3 { + color: var(--bs-blue); + font-size: 1.3rem; + margin-bottom: 1rem; + margin-top: 1.5rem; +} + +// Territory-gating for the partner wall on /introduction, done in CSS because +// that page hardcodes every logo in the markup. The integrators in the .ru +// group serve Russia and are shown only to Russian-language visitors. +// +// The replacement is PagesHelper#partner_logos, which makes the same split in +// Ruby and can therefore be tested. Until /introduction goes, this rule is what +// enforces it -- deleting it early would show the Russian integrators to +// everyone, which is precisely what the maintainers asked not to happen. +html:not([lang="ru"]) article.introduction .col.logo.ru { + display: none; +} + +// Page titles on the pre-relaunch pages: brand-coloured and 700, as they have +// always been. _base.scss styles `article > header > h2` at 600 and inherits +// the body colour, which is the new design; this selector is longer, so it wins +// wherever it applies -- and it applies exactly to pages rendered inside the +// .container wrapper, which is every page that is not full-bleed. The two rules +// therefore split cleanly along the same line as the layout's fullwidth switch, +// and this one disappears when the pages it belongs to do. +body > main > .container > article > header > h2 { + color: var(--bs-blue); + font-weight: 700; + margin: 1rem 0 1.5rem; +} diff --git a/app/assets/stylesheets/pages/_openwall.scss b/app/assets/stylesheets/pages/_openwall.scss new file mode 100644 index 00000000..93fa9fc8 --- /dev/null +++ b/app/assets/stylesheets/pages/_openwall.scss @@ -0,0 +1,12 @@ +// Open Wall gallery and slideshow. +.carousel-item .carousel-caption { + background-color: #1e1e1e66; + color: #f1f1f166; + a { color: #f1f1f166; } +} + +.carousel-item.active:hover .carousel-caption { + background-color: #1e1e1eff; + color: #f1f1f1ff; + a { color: #ffcc00; } +} diff --git a/app/javascript/application.js b/app/javascript/application.js index a0d70360..95fb5023 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -1,72 +1,35 @@ -import * as bootstrap from 'bootstrap' -import { decodeHeifToCanvas } from './heif' +// Bootstrap's JS, imported per-component rather than as `import * as bootstrap`. +// Each of these registers Bootstrap's data API on import, so the markup keeps +// working with no further wiring. +// +// The list is exactly what the views use: data-bs-toggle asks for dropdown, +// collapse and offcanvas, the Open Wall slideshow uses data-bs-ride, and +// src/zoom.js imports Modal directly (which is also what serves the one +// data-bs-dismiss="modal"). Nothing uses tooltip, popover, tab, alert or +// scrollspy. Adding markup that needs one of those means adding the import. +// +// This is a smaller win than it looks -- 189.4 KB to 181.8 KB unminified, +// because the components share most of their base. It is worth doing for the +// list above, which is a statement of what the site actually depends on. +import 'bootstrap/js/dist/collapse' +import 'bootstrap/js/dist/dropdown' +import 'bootstrap/js/dist/offcanvas' +import 'bootstrap/js/dist/carousel' -window.onload = (event) => { -// if ((window.navigator.language == 'ru' || window.navigator.language == 'ru-RU') && document.documentElement.lang !== 'ru') { -// location.href = location.href.replace(location.search, '').concat('?locale=ru'); -// } +import initZoom from './src/zoom' +import initExternalLinks from './src/external-links' +import initTimestamps from './src/timestamps' +import initConfirms from './src/confirms' +import initHeifViewer from './src/heif-viewer' - const modalZoom = document.getElementById('modalZoom'); - const zoom = new bootstrap.Modal(modalZoom, {}); - document.querySelectorAll('.img-zoom').forEach(el => { - el.addEventListener('click', ev => { - let b = modalZoom.querySelector('.modal-body'); - b.textContent = ''; - let i = document.createElement('img'); - // The tile is a downscaled copy; data-zoom, where present, names the - // full-resolution file so the modal is not an upscale of the thumbnail. - i.src = ev.target.dataset.zoom || ev.target.src; - i.classList.add('img-fluid'); - b.appendChild(i); - zoom.show(); - }); - }); - - document.querySelectorAll('a[href^="http"], a[rel^="external"]').forEach(el => { - el.target = '_blank'; - el.classList.add('external-link'); - }); - - document.querySelectorAll('span[data-timestamp]').forEach(el => { - const ts = el.dataset['timestamp']; - let date = new Date(ts * 1000); - el.textContent = date.toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }); - }); - - // For .warning and .danger buttons, ask confirmation on action. - document.querySelectorAll('.btn-danger, .btn-warning, .confirm').forEach(el => { - // for input or button, find parent form and attach listener to its submit event - if (el.nodeName === 'INPUT' || el.nodeName === 'BUTTON') { - while (el.nodeName !== 'FORM') el = el.parentNode - el.addEventListener('submit', ev => (!confirm('Are you sure?')) ? ev.preventDefault() : null) - } else { - el.addEventListener('click', ev => (!confirm('Are you sure?')) ? ev.preventDefault() : null) - } - }); -}; - -// Progressive enhancement: view the *original* HEIF snapshot decoded in the -// browser (the page otherwise shows a server-rendered JPEG). See heif.js. -document.addEventListener('click', async (ev) => { - const btn = ev.target.closest('[data-heif-view]'); - if (!btn) return; - const wrap = btn.closest('[data-heif-original]'); - const url = wrap && wrap.dataset.heifOriginal; - const canvas = wrap && wrap.querySelector('[data-heif-canvas]'); - const status = wrap && wrap.querySelector('[data-heif-status]'); - if (!url || !canvas) return; - - btn.disabled = true; - if (status) status.textContent = 'Decoding…'; - try { - const buffer = await (await fetch(url, { credentials: 'same-origin' })).arrayBuffer(); - const how = await decodeHeifToCanvas(buffer, canvas); - canvas.classList.remove('d-none'); - btn.classList.add('d-none'); - if (status) status.textContent = `Original HEIF, decoded in your browser (${how}).`; - } catch (e) { - console.warn('HEIF decode failed', e); - if (status) status.textContent = 'Your browser can’t decode this HEIF — showing the server preview.'; - btn.disabled = false; - } -}); +// DOMContentLoaded, not window.onload, which waits for every image and on the +// Open Wall meant the page sat unresponsive until the whole gallery had loaded. +// Assigning window.onload was also a single slot: a second assignment anywhere +// would have silently replaced all of this. +document.addEventListener('DOMContentLoaded', () => { + initZoom() + initExternalLinks() + initTimestamps() + initConfirms() + initHeifViewer() +}) diff --git a/app/javascript/src/confirms.js b/app/javascript/src/confirms.js new file mode 100644 index 00000000..974f603a --- /dev/null +++ b/app/javascript/src/confirms.js @@ -0,0 +1,12 @@ +// Ask for confirmation before destructive actions (.btn-danger, .btn-warning, .confirm). +export default function initConfirms() { + document.querySelectorAll('.btn-danger, .btn-warning, .confirm').forEach(el => { + // for input or button, find parent form and attach listener to its submit event + if (el.nodeName === 'INPUT' || el.nodeName === 'BUTTON') { + while (el && el.nodeName !== 'FORM') el = el.parentNode + if (el) el.addEventListener('submit', ev => (!confirm('Are you sure?')) ? ev.preventDefault() : null) + } else { + el.addEventListener('click', ev => (!confirm('Are you sure?')) ? ev.preventDefault() : null) + } + }) +} diff --git a/app/javascript/src/external-links.js b/app/javascript/src/external-links.js new file mode 100644 index 00000000..ffddfb14 --- /dev/null +++ b/app/javascript/src/external-links.js @@ -0,0 +1,8 @@ +// Open external links in a new tab and mark them visually. +export default function initExternalLinks() { + document.querySelectorAll('a[href^="http"], a[rel^="external"]').forEach(el => { + el.target = '_blank' + el.rel = 'noopener' + el.classList.add('external-link') + }) +} diff --git a/app/javascript/src/heif-viewer.js b/app/javascript/src/heif-viewer.js new file mode 100644 index 00000000..9483dbbe --- /dev/null +++ b/app/javascript/src/heif-viewer.js @@ -0,0 +1,31 @@ +import { decodeHeifToCanvas } from '../heif' + +// Progressive enhancement: view the *original* HEIF snapshot decoded in the +// browser. The page otherwise shows a server-rendered JPEG, because HEIF is +// decodable natively only by Safari. See heif.js for the decoder itself. +export default function initHeifViewer() { + document.addEventListener('click', async ev => { + const btn = ev.target.closest('[data-heif-view]') + if (!btn) return + + const wrap = btn.closest('[data-heif-original]') + const url = wrap && wrap.dataset.heifOriginal + const canvas = wrap && wrap.querySelector('[data-heif-canvas]') + const status = wrap && wrap.querySelector('[data-heif-status]') + if (!url || !canvas) return + + btn.disabled = true + if (status) status.textContent = 'Decoding…' + try { + const buffer = await (await fetch(url, { credentials: 'same-origin' })).arrayBuffer() + const how = await decodeHeifToCanvas(buffer, canvas) + canvas.classList.remove('d-none') + btn.classList.add('d-none') + if (status) status.textContent = `Original HEIF, decoded in your browser (${how}).` + } catch (e) { + console.warn('HEIF decode failed', e) + if (status) status.textContent = 'Your browser can’t decode this HEIF — showing the server preview.' + btn.disabled = false + } + }) +} diff --git a/app/javascript/src/timestamps.js b/app/javascript/src/timestamps.js new file mode 100644 index 00000000..1e70fd60 --- /dev/null +++ b/app/javascript/src/timestamps.js @@ -0,0 +1,8 @@ +// Render unix timestamps in the visitor's locale and timezone. +export default function initTimestamps() { + document.querySelectorAll('span[data-timestamp]').forEach(el => { + const ts = el.dataset['timestamp'] + const date = new Date(ts * 1000) + el.textContent = date.toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) + }) +} diff --git a/app/javascript/src/zoom.js b/app/javascript/src/zoom.js new file mode 100644 index 00000000..dcf7cb51 --- /dev/null +++ b/app/javascript/src/zoom.js @@ -0,0 +1,28 @@ +import Modal from 'bootstrap/js/dist/modal' + +// Click-to-zoom for images marked .img-zoom; needs the #modalZoom markup. +export default function initZoom() { + const modalZoom = document.getElementById('modalZoom') + // Most pages have no zoomable image and therefore no modal. Constructing a + // Modal on null throws, and the throw took every later initialiser on the + // page down with it, because they all ran from one window.onload handler. + if (!modalZoom) return + + const zoom = new Modal(modalZoom, {}) + + // Delegated, so images added after load still zoom. + document.addEventListener('click', ev => { + const img = ev.target.closest('.img-zoom') + if (!img) return + + const body = modalZoom.querySelector('.modal-body') + body.textContent = '' + const full = document.createElement('img') + // The tile is a downscaled copy; data-zoom, where present, names the + // full-resolution file so the modal is not an upscale of the thumbnail. + full.src = img.dataset.zoom || img.src + full.classList.add('img-fluid') + body.appendChild(full) + zoom.show() + }) +} diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index df2b5c8e..36b67c7e 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -8,7 +8,7 @@ <%= csp_meta_tag %> <%= stylesheet_link_tag 'application' %> - <%= javascript_include_tag 'application', 'data-turbo-track': 'reload', defer: true %> + <%= javascript_include_tag 'application', defer: true %> diff --git a/package.json b/package.json index 6b82ac81..6706c6db 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,8 @@ "name": "app", "packageManager": "yarn@4.0.2", "dependencies": { - "@hotwired/stimulus": "^3.2.2", - "@hotwired/turbo-rails": "^7.3.0", + "@fontsource/ibm-plex-mono": "^5.2.7", + "@fontsource/ibm-plex-sans": "^5.2.8", "@popperjs/core": "^2.11.8", "autoprefixer": "^10.4.16", "bootstrap": "^5.3.2", @@ -16,6 +16,7 @@ }, "scripts": { "build": "esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --public-path=/assets", + "build:fonts": "node tools/copy-fonts.mjs", "build:css:compile": "sass ./app/assets/stylesheets/application.bootstrap.scss:./app/assets/builds/application.css --no-source-map --load-path=node_modules", "build:css:prefix": "postcss ./app/assets/builds/application.css --use=autoprefixer --output=./app/assets/builds/application.css", "build:css": "yarn build:css:compile && yarn build:css:prefix", diff --git a/public/fonts/bootstrap-icons.woff b/public/fonts/bootstrap-icons.woff new file mode 100644 index 00000000..dbeeb055 Binary files /dev/null and b/public/fonts/bootstrap-icons.woff differ diff --git a/public/fonts/bootstrap-icons.woff2 b/public/fonts/bootstrap-icons.woff2 new file mode 100644 index 00000000..87032d17 Binary files /dev/null and b/public/fonts/bootstrap-icons.woff2 differ diff --git a/public/fonts/ibm-plex-mono-cyrillic-400-normal.woff2 b/public/fonts/ibm-plex-mono-cyrillic-400-normal.woff2 new file mode 100644 index 00000000..20e72b01 Binary files /dev/null and b/public/fonts/ibm-plex-mono-cyrillic-400-normal.woff2 differ diff --git a/public/fonts/ibm-plex-mono-cyrillic-600-normal.woff2 b/public/fonts/ibm-plex-mono-cyrillic-600-normal.woff2 new file mode 100644 index 00000000..56fa97f5 Binary files /dev/null and b/public/fonts/ibm-plex-mono-cyrillic-600-normal.woff2 differ diff --git a/public/fonts/ibm-plex-mono-latin-400-normal.woff2 b/public/fonts/ibm-plex-mono-latin-400-normal.woff2 new file mode 100644 index 00000000..0804aaff Binary files /dev/null and b/public/fonts/ibm-plex-mono-latin-400-normal.woff2 differ diff --git a/public/fonts/ibm-plex-mono-latin-600-normal.woff2 b/public/fonts/ibm-plex-mono-latin-600-normal.woff2 new file mode 100644 index 00000000..67aeeb01 Binary files /dev/null and b/public/fonts/ibm-plex-mono-latin-600-normal.woff2 differ diff --git a/public/fonts/ibm-plex-mono-latin-ext-400-normal.woff2 b/public/fonts/ibm-plex-mono-latin-ext-400-normal.woff2 new file mode 100644 index 00000000..732c64d4 Binary files /dev/null and b/public/fonts/ibm-plex-mono-latin-ext-400-normal.woff2 differ diff --git a/public/fonts/ibm-plex-mono-latin-ext-600-normal.woff2 b/public/fonts/ibm-plex-mono-latin-ext-600-normal.woff2 new file mode 100644 index 00000000..c0b57de4 Binary files /dev/null and b/public/fonts/ibm-plex-mono-latin-ext-600-normal.woff2 differ diff --git a/public/fonts/ibm-plex-sans-cyrillic-400-normal.woff2 b/public/fonts/ibm-plex-sans-cyrillic-400-normal.woff2 new file mode 100644 index 00000000..5ad40b40 Binary files /dev/null and b/public/fonts/ibm-plex-sans-cyrillic-400-normal.woff2 differ diff --git a/public/fonts/ibm-plex-sans-cyrillic-500-normal.woff2 b/public/fonts/ibm-plex-sans-cyrillic-500-normal.woff2 new file mode 100644 index 00000000..189f9dcf Binary files /dev/null and b/public/fonts/ibm-plex-sans-cyrillic-500-normal.woff2 differ diff --git a/public/fonts/ibm-plex-sans-cyrillic-600-normal.woff2 b/public/fonts/ibm-plex-sans-cyrillic-600-normal.woff2 new file mode 100644 index 00000000..2c315c47 Binary files /dev/null and b/public/fonts/ibm-plex-sans-cyrillic-600-normal.woff2 differ diff --git a/public/fonts/ibm-plex-sans-cyrillic-700-normal.woff2 b/public/fonts/ibm-plex-sans-cyrillic-700-normal.woff2 new file mode 100644 index 00000000..2607f95c Binary files /dev/null and b/public/fonts/ibm-plex-sans-cyrillic-700-normal.woff2 differ diff --git a/public/fonts/ibm-plex-sans-latin-400-normal.woff2 b/public/fonts/ibm-plex-sans-latin-400-normal.woff2 new file mode 100644 index 00000000..f0ee65dd Binary files /dev/null and b/public/fonts/ibm-plex-sans-latin-400-normal.woff2 differ diff --git a/public/fonts/ibm-plex-sans-latin-500-normal.woff2 b/public/fonts/ibm-plex-sans-latin-500-normal.woff2 new file mode 100644 index 00000000..6d5527ee Binary files /dev/null and b/public/fonts/ibm-plex-sans-latin-500-normal.woff2 differ diff --git a/public/fonts/ibm-plex-sans-latin-600-normal.woff2 b/public/fonts/ibm-plex-sans-latin-600-normal.woff2 new file mode 100644 index 00000000..08c0d5ad Binary files /dev/null and b/public/fonts/ibm-plex-sans-latin-600-normal.woff2 differ diff --git a/public/fonts/ibm-plex-sans-latin-700-normal.woff2 b/public/fonts/ibm-plex-sans-latin-700-normal.woff2 new file mode 100644 index 00000000..953f94fa Binary files /dev/null and b/public/fonts/ibm-plex-sans-latin-700-normal.woff2 differ diff --git a/public/fonts/ibm-plex-sans-latin-ext-400-normal.woff2 b/public/fonts/ibm-plex-sans-latin-ext-400-normal.woff2 new file mode 100644 index 00000000..ef4be2d9 Binary files /dev/null and b/public/fonts/ibm-plex-sans-latin-ext-400-normal.woff2 differ diff --git a/public/fonts/ibm-plex-sans-latin-ext-500-normal.woff2 b/public/fonts/ibm-plex-sans-latin-ext-500-normal.woff2 new file mode 100644 index 00000000..6b51734c Binary files /dev/null and b/public/fonts/ibm-plex-sans-latin-ext-500-normal.woff2 differ diff --git a/public/fonts/ibm-plex-sans-latin-ext-600-normal.woff2 b/public/fonts/ibm-plex-sans-latin-ext-600-normal.woff2 new file mode 100644 index 00000000..c19b3c63 Binary files /dev/null and b/public/fonts/ibm-plex-sans-latin-ext-600-normal.woff2 differ diff --git a/public/fonts/ibm-plex-sans-latin-ext-700-normal.woff2 b/public/fonts/ibm-plex-sans-latin-ext-700-normal.woff2 new file mode 100644 index 00000000..bacc88cd Binary files /dev/null and b/public/fonts/ibm-plex-sans-latin-ext-700-normal.woff2 differ diff --git a/tools/copy-fonts.mjs b/tools/copy-fonts.mjs new file mode 100644 index 00000000..3d56e747 --- /dev/null +++ b/tools/copy-fonts.mjs @@ -0,0 +1,71 @@ +// Copy the web fonts the stylesheet references out of node_modules and into +// public/fonts/, where they are served directly and are not fingerprinted. +// +// The files are committed, so this is not part of any build -- neither the +// Dockerfile nor CI runs it, and .dockerignore excludes tools/ from the image +// entirely. Run it by hand (`yarn build:fonts`) after bumping @fontsource or +// bootstrap-icons, and commit whatever changes. +// +// Committed rather than generated because public/ is served by the web server +// without going through Sprockets: a generated file would have to exist before +// assets:precompile in the image build and before the suite renders a page in +// CI, for no benefit -- these change perhaps once a year. +// +// Why self-hosted at all: the stylesheet used to @import fonts.googleapis.com +// and cdn.jsdelivr.net, which made every visitor announce themselves to two +// third parties before the page could paint, and put first paint behind a +// DNS lookup we do not control. + +import { mkdirSync, copyFileSync, existsSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..') +const dest = join(root, 'public', 'fonts') + +// Subsets and weights must match the @font-face rules in +// app/assets/stylesheets/_fonts.scss. Copying a file the stylesheet never asks +// for wastes bytes in the repo; missing one that it does ask for is a 404 and a +// silent fallback to the system font. +const SUBSETS = ['latin', 'latin-ext', 'cyrillic'] +const FACES = [ + { pkg: '@fontsource/ibm-plex-sans', slug: 'ibm-plex-sans', weights: [400, 500, 600, 700] }, + { pkg: '@fontsource/ibm-plex-mono', slug: 'ibm-plex-mono', weights: [400, 600] }, +] + +mkdirSync(dest, { recursive: true }) + +let copied = 0 +const missing = [] + +for (const { pkg, slug, weights } of FACES) { + for (const subset of SUBSETS) { + for (const weight of weights) { + const name = `${slug}-${subset}-${weight}-normal.woff2` + const from = join(root, 'node_modules', pkg, 'files', name) + if (!existsSync(from)) { missing.push(name); continue } + copyFileSync(from, join(dest, name)) + copied++ + } + } +} + +// bootstrap-icons ships its own font next to the stylesheet we import. The +// $bootstrap-icons-font-dir override points it at /fonts, so both formats have +// to land here -- woff2 for everything current, woff as the fallback the +// generated @font-face still lists. +for (const name of ['bootstrap-icons.woff2', 'bootstrap-icons.woff']) { + const from = join(root, 'node_modules', 'bootstrap-icons', 'font', 'fonts', name) + if (!existsSync(from)) { missing.push(name); continue } + copyFileSync(from, join(dest, name)) + copied++ +} + +if (missing.length) { + console.error(`copy-fonts: ${missing.length} file(s) not found in node_modules:`) + for (const name of missing) console.error(` ${name}`) + console.error('Run `yarn install` first, or reconcile the lists above with _fonts.scss.') + process.exit(1) +} + +console.log(`copy-fonts: ${copied} file(s) -> public/fonts/`) diff --git a/yarn.lock b/yarn.lock index 50d3a757..679bbd43 100644 --- a/yarn.lock +++ b/yarn.lock @@ -159,27 +159,17 @@ __metadata: languageName: node linkType: hard -"@hotwired/stimulus@npm:^3.2.2": - version: 3.2.2 - resolution: "@hotwired/stimulus@npm:3.2.2" - checksum: 3793919e353d28424f57d07e6b634bf6e8821c227acfa2a608aaa8bdfe7e80479acef12705168ca866f3c231399f4a939fb0cd089aaae04c406b3d885a3b35ea +"@fontsource/ibm-plex-mono@npm:^5.2.7": + version: 5.3.0 + resolution: "@fontsource/ibm-plex-mono@npm:5.3.0" + checksum: 4aaf5a3c9efc7c32de4fa46ff3187ac63720904beab825f331ac01800038899761da23270f09d3fb5cff22f7cdc366904834854e2107229ad60833bba71c223d languageName: node linkType: hard -"@hotwired/turbo-rails@npm:^7.3.0": - version: 7.3.0 - resolution: "@hotwired/turbo-rails@npm:7.3.0" - dependencies: - "@hotwired/turbo": "npm:^7.3.0" - "@rails/actioncable": "npm:^7.0" - checksum: 1b2a5bc8767f0366c91a80a6bac178b8b02ca607add5b14b637f128d9ff08629bf3761931cc892acd46175782649ebdffa76dd45fc7e23f44bff8c06ca452d0b - languageName: node - linkType: hard - -"@hotwired/turbo@npm:^7.3.0": - version: 7.3.0 - resolution: "@hotwired/turbo@npm:7.3.0" - checksum: be7d273808d64e06682f413ea007b555ecc0077cfd846754fe3b1bca06ca1b9473184d95528f7de0f3002bbc7895d58a067845ba363c549229c3bc5870c981e2 +"@fontsource/ibm-plex-sans@npm:^5.2.8": + version: 5.3.0 + resolution: "@fontsource/ibm-plex-sans@npm:5.3.0" + checksum: 8e4c7a849e8167c8de1905282a62ff312a4266e364e3c436fa6d89d650bbb30b3386157ae31af3783e00f85fa64fd7e26bee7ca232db7d22fdc082f6b531edb5 languageName: node linkType: hard @@ -260,13 +250,6 @@ __metadata: languageName: node linkType: hard -"@rails/actioncable@npm:^7.0": - version: 7.0.8 - resolution: "@rails/actioncable@npm:7.0.8" - checksum: e9bf7cfcfc6877d320e8b637952f0fac227212631c1c169f9daaf5da545c863aea3d5a9837e661930303003c5885e6d0050b9d6d2b976d34722d9296dedd4f71 - languageName: node - linkType: hard - "abbrev@npm:1": version: 1.1.1 resolution: "abbrev@npm:1.1.1" @@ -344,8 +327,8 @@ __metadata: version: 0.0.0-use.local resolution: "app@workspace:." dependencies: - "@hotwired/stimulus": "npm:^3.2.2" - "@hotwired/turbo-rails": "npm:^7.3.0" + "@fontsource/ibm-plex-mono": "npm:^5.2.7" + "@fontsource/ibm-plex-sans": "npm:^5.2.8" "@popperjs/core": "npm:^2.11.8" autoprefixer: "npm:^10.4.16" bootstrap: "npm:^5.3.2"