fixed typographic traitment, moved js alog to header defer script for speed, no more flickering

This commit is contained in:
2026-09-11 21:36:42 +02:00
parent 4efafd0e22
commit 9dc1272631
10 changed files with 268 additions and 182 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -2,17 +2,19 @@
@font-face {
font-family: 'Epilogue';
src: url('Epilogue-Italic-VariableFont_wght.ttf') format('woff2');
src: url('Epilogue-Italic-VariableFont_wght.ttf') format('woff2') tech(variations);
src: url('Epilogue-Italic-VariableFont_wght.ttf') format('truetype');
src: url('Epilogue-Italic-VariableFont_wght.ttf') format('truetype') tech(variations);
font-weight: 100 900;
font-style: italic;
font-display: block;
}
@font-face {
font-family: 'Epilogue';
src: url('Epilogue-VariableFont_wght.ttf') format('woff2');
src: url('Epilogue-VariableFont_wght.ttf') format('woff2') tech(variations);
src: url('Epilogue-VariableFont_wght.ttf') format('truetype');
src: url('Epilogue-VariableFont_wght.ttf') format('truetype') tech(variations);
font-weight: 100 900;
font-style: normal;
font-display: block;
}
+1 -168
View File
@@ -3,8 +3,6 @@ import '../scss/main.scss'
import "splitting/dist/splitting.css";
import "splitting/dist/splitting-cells.css";
import Splitting from "splitting";
import { hyphenateSync } from "hyphen/fr";
import { gsap, Power1 } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import Lenis from 'lenis';
@@ -53,7 +51,6 @@ gsap.registerPlugin(ScrollTrigger);
console.log('LeShedTheme init()');
console.log('deploy test to delete');
initTitles();
initBurgerMenu();
initSmoothScroll();
initHomeColumnScrollSync();
@@ -71,170 +68,6 @@ gsap.registerPlugin(ScrollTrigger);
})
}
function randomInt(min, max) {
return Math.floor(Math.random() * (max - min)) + min;
}
// Rendu typographique des titres. Police Epilogue = axe variable `wght`
// (100900) uniquement ; l'italique est un fichier séparé (font-style).
const TITLE_STYLE = {
// 3 groupes de graisse : thin = majorité (défaut), medium = quelques,
// bold = les pics. Les fines ne sont pas comptées (c'est le reste).
wghtThin: { min: 100, max: 200 }, // majorité (défaut)
wghtMedium: { min: 200, max: 400 }, // quelques
wghtBold: { min: 800, max: 900 }, // pics
boldRatio: 0.28, // proportion de pics gras par mot
mediumRatio: 0.12, // proportion de medium par mot
italicOnBold: 0.6, // proba d'italique sur un pic
italicOnMedium: 0.4, // proba d'italique sur un medium
italicOnThin: 0.30, // proba d'italique sur une fine
};
function initTitles() {
const titles = document.querySelectorAll('\
#block-leshed-identitedusite>a, \
article.node-type-projet>header>h2,\
article.node-type-projet>.cols>.left>h2,\
article.node-type-projet.view-mode-full h2.node-title,\
article.node-type-evenement>header>h2,\
article.node-type-evenement>header>.field_dates>time,\
article.node-type-evenement.view-mode-full h2.node-title,\
article.node-type-evenement.view-mode-full .field_dates.digit-dates span.date-part\
');
for (const txt of titles) {
txt.classList.add("variable-title");
const splitted = Splitting({ target: txt, by: 'chars' });
for (const word of splitted[0].words) {
const chars = Array.from(word.getElementsByClassName('char'));
styleWordChars(chars);
hyphenateWord(chars);
}
}
}
/**
* Tire k positions réparties régulièrement dans [0, n[ : le mot est découpé
* en k cases contiguës, une position au hasard par case (aléa contrôlé, pas
* de paquets). Les positions déjà prises par un autre groupe sont évitées.
*/
function spreadPositions(n, k, exclude = new Set()) {
const set = new Set();
for (let i = 0; i < k; i++) {
const start = Math.floor((i * n) / k);
const end = Math.floor(((i + 1) * n) / k); // borne exclue
const span = Math.max(1, end - start);
let pos = randomInt(start, start + span);
let guard = 0;
while ((exclude.has(pos) || set.has(pos)) && guard < span) {
pos = start + ((pos - start + 1) % span);
guard++;
}
set.add(pos);
}
return set;
}
/**
* Applique graisse + italique caractère par caractère.
*
* Majorité de fines ; quelques medium et quelques pics gras, chacun réparti
* régulièrement dans le mot. L'italique est corrélé à la graisse.
*/
function styleWordChars(chars) {
const n = chars.length;
if (!n) return;
// Accents répartis régulièrement : d'abord les pics gras, puis les medium
// (en évitant les positions déjà grasses). Le reste sera fin.
const bold = spreadPositions(n, Math.round(n * TITLE_STYLE.boldRatio));
const medium = spreadPositions(n, Math.round(n * TITLE_STYLE.mediumRatio), bold);
// 1) Décide graisse + italique pour chaque caractère.
const decisions = chars.map((char, i) => {
let range, pItal;
if (bold.has(i)) {
range = TITLE_STYLE.wghtBold;
pItal = TITLE_STYLE.italicOnBold;
}
else if (medium.has(i)) {
range = TITLE_STYLE.wghtMedium;
pItal = TITLE_STYLE.italicOnMedium;
}
else {
range = TITLE_STYLE.wghtThin;
pItal = TITLE_STYLE.italicOnThin;
}
return {
char,
wght: randomInt(range.min, range.max + 1),
ital: Math.random() < pItal,
};
});
// 2) Au moins une italique par mot : si aucune, on en force une, de
// préférence sur un pic gras ou un medium, sinon sur une lettre au hasard.
if (!decisions.some((d) => d.ital)) {
const accents = [...bold, ...medium];
const idx = accents.length ? accents[randomInt(0, accents.length)] : randomInt(0, n);
decisions[idx].ital = true;
}
// 3) Pas plus de 2 italiques consécutives : on casse les séries de 3+.
let run = 0;
for (const d of decisions) {
if (d.ital) {
run += 1;
if (run > 2) {
d.ital = false;
run = 0;
}
}
else {
run = 0;
}
}
// 4) Applique.
for (const { char, wght, ital } of decisions) {
char.style.fontVariationSettings = `'wght' ${wght}`;
char.style.fontStyle = ital ? 'italic' : 'normal';
char.classList.toggle('italic', ital);
char.classList.toggle('normal', !ital);
}
}
/**
* Insère des césures logicielles (U+00AD) aux points de coupure valides.
*
* Comme chaque lettre est un span (display:inline) sans « run » de texte
* continu, `hyphens: auto` est inopérant : on calcule donc les points de
* césure (motifs FR) et on insère un soft hyphen — en nœud texte, entre les
* lettres concernées — qui n'autorise une coupure (avec tiret) qu'à ces
* endroits. Les mots trop longs coupent proprement au lieu de casser
* n'importe où.
*/
function hyphenateWord(chars) {
if (chars.length < 5) return;
const SOFT = '\u00AD';
const text = chars.map((c) => c.textContent).join('');
const hyphenated = hyphenateSync(text);
if (!hyphenated.includes(SOFT)) return;
let letter = 0;
for (const ch of hyphenated) {
if (ch === SOFT) {
const target = chars[letter];
if (letter > 0 && target && target.parentNode) {
target.parentNode.insertBefore(document.createTextNode(SOFT), target);
}
}
else {
letter++;
}
}
}
function initBurgerMenu() {
let header_right = document.getElementById('burger-btn');
header_right.parentElement.addEventListener('click', function(e){
@@ -373,7 +206,7 @@ gsap.registerPlugin(ScrollTrigger);
const leftFreezeStart = () => {
const overflow = leftColumn.offsetHeight - scroller.clientHeight;
if (overflow > 0) return overflow;
return pageTitle ? pageTitle.offsetHeight * (1 - titleScaleTarget) : 0;
return initheaderH ? initheaderH.offsetHeight * (1 - titleScaleTarget) : 0;
};
gsap_mm.add('(min-width: 768px)', () => {
@@ -0,0 +1,173 @@
// Chargé en defer dans le <head> : s'exécute avant main.js (defer, fin de body).
import Splitting from "splitting";
import { hyphenateSync } from "hyphen/fr";
// À garder identique à $variable-title-selectors (main.scss), qui masque ces
// titres jusqu'à `is-ready`.
const TITLE_SELECTORS = '\
#block-leshed-identitedusite>a, \
article.node-type-projet>header>h2,\
article.node-type-projet>.cols>.left>h2,\
article.node-type-projet.view-mode-full h2.node-title,\
article.node-type-evenement>header>h2,\
article.node-type-evenement>header>.field_dates>time,\
article.node-type-evenement.view-mode-full h2.node-title,\
article.node-type-evenement.view-mode-full .field_dates.digit-dates span.date-part\
';
// Police Epilogue = axe variable `wght` (100900) uniquement ; l'italique est
// un fichier séparé (font-style).
const TITLE_STYLE = {
// 3 groupes de graisse : thin = majorité (défaut), medium = quelques,
// bold = les pics. Les fines ne sont pas comptées (c'est le reste).
wghtThin: { min: 100, max: 200 }, // majorité (défaut)
wghtMedium: { min: 200, max: 400 }, // quelques
wghtBold: { min: 800, max: 900 }, // pics
boldRatio: 0.28, // proportion de pics gras par mot
mediumRatio: 0.12, // proportion de medium par mot
italicOnBold: 0.6, // proba d'italique sur un pic
italicOnMedium: 0.4, // proba d'italique sur un medium
italicOnThin: 0.30, // proba d'italique sur une fine
};
function randomInt(min, max) {
return Math.floor(Math.random() * (max - min)) + min;
}
function initTitles() {
const titles = document.querySelectorAll(TITLE_SELECTORS);
for (const txt of titles) {
txt.classList.add("variable-title");
const splitted = Splitting({ target: txt, by: 'chars' });
for (const word of splitted[0].words) {
const chars = Array.from(word.getElementsByClassName('char'));
styleWordChars(chars);
hyphenateWord(chars);
}
txt.classList.add("is-ready");
}
}
/**
* Tire k positions réparties régulièrement dans [0, n[ : le mot est découpé
* en k cases contiguës, une position au hasard par case (aléa contrôlé, pas
* de paquets). Les positions déjà prises par un autre groupe sont évitées.
*/
function spreadPositions(n, k, exclude = new Set()) {
const set = new Set();
for (let i = 0; i < k; i++) {
const start = Math.floor((i * n) / k);
const end = Math.floor(((i + 1) * n) / k); // borne exclue
const span = Math.max(1, end - start);
let pos = randomInt(start, start + span);
let guard = 0;
while ((exclude.has(pos) || set.has(pos)) && guard < span) {
pos = start + ((pos - start + 1) % span);
guard++;
}
set.add(pos);
}
return set;
}
/**
* Applique graisse + italique caractère par caractère.
*
* Majorité de fines ; quelques medium et quelques pics gras, chacun réparti
* régulièrement dans le mot. L'italique est corrélé à la graisse.
*/
function styleWordChars(chars) {
const n = chars.length;
if (!n) return;
// Accents répartis régulièrement : d'abord les pics gras, puis les medium
// (en évitant les positions déjà grasses). Le reste sera fin.
const bold = spreadPositions(n, Math.round(n * TITLE_STYLE.boldRatio));
const medium = spreadPositions(n, Math.round(n * TITLE_STYLE.mediumRatio), bold);
// 1) Décide graisse + italique pour chaque caractère.
const decisions = chars.map((char, i) => {
let range, pItal;
if (bold.has(i)) {
range = TITLE_STYLE.wghtBold;
pItal = TITLE_STYLE.italicOnBold;
}
else if (medium.has(i)) {
range = TITLE_STYLE.wghtMedium;
pItal = TITLE_STYLE.italicOnMedium;
}
else {
range = TITLE_STYLE.wghtThin;
pItal = TITLE_STYLE.italicOnThin;
}
return {
char,
wght: randomInt(range.min, range.max + 1),
ital: Math.random() < pItal,
};
});
// 2) Au moins une italique par mot : si aucune, on en force une, de
// préférence sur un pic gras ou un medium, sinon sur une lettre au hasard.
if (!decisions.some((d) => d.ital)) {
const accents = [...bold, ...medium];
const idx = accents.length ? accents[randomInt(0, accents.length)] : randomInt(0, n);
decisions[idx].ital = true;
}
// 3) Pas plus de 2 italiques consécutives : on casse les séries de 3+.
let run = 0;
for (const d of decisions) {
if (d.ital) {
run += 1;
if (run > 2) {
d.ital = false;
run = 0;
}
}
else {
run = 0;
}
}
// 4) Applique.
for (const { char, wght, ital } of decisions) {
char.style.fontVariationSettings = `'wght' ${wght}`;
char.style.fontStyle = ital ? 'italic' : 'normal';
char.classList.toggle('italic', ital);
char.classList.toggle('normal', !ital);
}
}
/**
* Insère des césures logicielles (U+00AD) aux points de coupure valides.
*
* Comme chaque lettre est un span (display:inline) sans « run » de texte
* continu, `hyphens: auto` est inopérant : on calcule donc les points de
* césure (motifs FR) et on insère un soft hyphen — en nœud texte, entre les
* lettres concernées — qui n'autorise une coupure (avec tiret) qu'à ces
* endroits. Les mots trop longs coupent proprement au lieu de casser
* n'importe où.
*/
function hyphenateWord(chars) {
if (chars.length < 5) return;
const SOFT = '\u00AD';
const text = chars.map((c) => c.textContent).join('');
const hyphenated = hyphenateSync(text);
if (!hyphenated.includes(SOFT)) return;
let letter = 0;
for (const ch of hyphenated) {
if (ch === SOFT) {
const target = chars[letter];
if (letter > 0 && target && target.parentNode) {
target.parentNode.insertBefore(document.createTextNode(SOFT), target);
}
}
else {
letter++;
}
}
}
initTitles();
@@ -237,6 +237,39 @@ header[role="banner"]{
// }
}
// Titres traités par assets/js/titles.js : garder identique à TITLE_SELECTORS.
$variable-title-selectors: (
'#block-leshed-identitedusite>a',
'article.node-type-projet>header>h2',
'article.node-type-projet>.cols>.left>h2',
'article.node-type-projet.view-mode-full h2.node-title',
'article.node-type-evenement>header>h2',
'article.node-type-evenement>header>.field_dates>time',
'article.node-type-evenement.view-mode-full h2.node-title',
'article.node-type-evenement.view-mode-full .field_dates.digit-dates span.date-part',
);
// Avant le split : même métrique qu'après, pour éviter un saut de mise en page.
:where(#{$variable-title-selectors}):not(.splitting){
margin: 0;
font-weight: 200;
text-transform: lowercase;
line-height: 1;
letter-spacing: -0.1em;
}
// Masqués (place conservée) jusqu'au split ; réaffichés après 3 s si le JS échoue.
@media (scripting: enabled){
:where(#{$variable-title-selectors}):not(.is-ready){
visibility: hidden;
animation: leshed-title-failsafe 0s 3s forwards;
}
}
@keyframes leshed-title-failsafe{
to{ visibility: visible; }
}
@mixin nodeListe{
>header{
+20 -1
View File
@@ -1,13 +1,32 @@
# Main theme library.
global:
js:
assets/dist/main.js: {}
# defer : s'exécute après titles.js (defer dans le head), ordre du document.
assets/dist/main.js:
attributes:
defer: true
# assets/js/main.js: {}
css:
theme:
assets/dist/main.css: {}
# assets/scss/main.css: {}
# Rendu typographique des titres, au plus tôt (voir assets/js/titles.js).
titles:
header: true
js:
assets/dist/titles.js:
attributes:
defer: true
titles_hmr:
header: true
js:
http://localhost:5173/assets/js/titles.js:
type: external
attributes:
type: module
vitehmr:
js:
http://localhost:5173/@vite/client:
+21
View File
@@ -12,8 +12,29 @@ function leshed_page_attachments_alter(&$page) {
// $attachments['#attached']['html_head'][] = [['src' => 'http://localhost:5173/assets/js/main.js', 'type' => 'module'],'vite-main'];
// $page['#attached']['library'][] = 'http://localhost:5173/assets/js/main.js';
$page['#attached']['library'][] = 'leshed/vitehmr';
$page['#attached']['library'][] = 'leshed/titles_hmr';
}else{
$page['#attached']['library'][] = 'leshed/global';
$page['#attached']['library'][] = 'leshed/titles';
// Must match exactly the url() resolved from assets/dist/main.css, or the
// font is downloaded twice.
$fonts_path = base_path() . \Drupal::service('extension.list.theme')->getPath('leshed') . '/assets/dist/fonts/';
foreach (['Epilogue-VariableFont_wght.ttf', 'Epilogue-Italic-VariableFont_wght.ttf'] as $font) {
$page['#attached']['html_head'][] = [
[
'#tag' => 'link',
'#attributes' => [
'rel' => 'preload',
'href' => $fonts_path . $font,
'as' => 'font',
'type' => 'font/ttf',
'crossorigin' => 'anonymous',
],
],
'leshed_preload_' . $font,
];
}
}
}
/**
+6 -2
View File
@@ -31,9 +31,13 @@ export default defineConfig({
build: {
outDir: 'assets/dist',
rollupOptions: {
input: 'assets/js/main.js',
// titles.js est chargé à part dans le <head> (librairie leshed/titles).
input: {
main: 'assets/js/main.js',
titles: 'assets/js/titles.js',
},
output: {
entryFileNames: 'main.js',
entryFileNames: '[name].js',
// Sans distinction d'extension, tous les assets émis (CSS mais aussi
// les polices référencées en url() dans fonts.scss) écrasaient le
// même fichier main.css au hasard de l'ordre de build.