fix(ui): Cyrillic/Greek weights, html lang, weight normalisation (#1780)

Load Roboto 600/700 and DM Sans 700 so Cyrillic and Greek headings render
real semibold and bold faces instead of a synthetic bold, keep
<html lang> in step with the UI language, and move every font weight onto
the 400/500/600/700 scale (JetBrains Mono at 500 or lighter; the dashboard
LIVE badge now uses the interface font at 700).

Add the `styles:font-weights:validate` ratchet guard and its CI step. It
reads stylesheets much as Sass and the browser do (cascade, layers,
mixins, content blocks, `@extend`, `@at-root`, `:is()`/`:where()`,
keyframes) and lists what it deliberately does not trace in its header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 authored and GitHub committed 2026-10-04 08:55:23 +02:00
1 parent ab8460338a
commit e8b181fcea
52 files changed
+11216 -97

No files matched your search

+10 -1
View File
@@ -1,9 +1,14 @@
import { EventEmitter } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { Actions } from '@ngrx/effects';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { TranslateService } from '@ngx-translate/core';
import {
DefaultLangChangeEvent,
LangChangeEvent,
TranslateService,
} from '@ngx-translate/core';
import {
EpgRuntimeBridgeService,
EpgService,
@@ -150,7 +155,11 @@ describe('AppComponent', () => {
MockProvider(TranslateService, {
instant: jest.fn((key: string) => key),
setDefaultLang: jest.fn(),
getDefaultLang: jest.fn(() => 'en'),
use: jest.fn(),
onLangChange: new EventEmitter<LangChangeEvent>(),
onDefaultLangChange:
new EventEmitter<DefaultLangChangeEvent>(),
}),
// The real service imports locale chunks; the language switch
// is gated on it, so it must resolve deterministically here.
+3
View File
@@ -48,6 +48,7 @@ import { PlaybackKeepAwakeService } from './services/playback-keep-awake.service
import { PlaylistOpenRequestService } from './services/playlist-open-request.service';
import { AppUpdateNotificationPanelComponent } from './app-update-notification-panel.component';
import { AppStartupStatusComponent } from './app-startup-status.component';
import { syncDocumentLanguage } from './services/document-language';
const debugAppComponent = createDevLogger('AppComponent');
@@ -98,6 +99,8 @@ export class AppComponent implements OnInit {
private readonly DEFAULT_LANG = Language.ENGLISH;
constructor() {
syncDocumentLanguage();
// Body-level class (like 'dark-theme') so layout adjustments also
// reach content rendered outside app-root, e.g. cdk-overlay content.
if (this.runtime.usesCustomWindowControls) {
@@ -0,0 +1,66 @@
import { DOCUMENT } from '@angular/common';
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { syncDocumentLanguage, toDocumentLanguage } from './document-language';
@Component({ template: '' })
class HostComponent {
constructor() {
syncDocumentLanguage();
}
}
describe('document language', () => {
it.each([
['ru', 'ru'],
['by', 'be'],
['zhtw', 'zh-TW'],
['tr', 'tr'],
['', 'en'],
[undefined, 'en'],
])('maps %p to <html lang="%s">', (appLanguage, expected) => {
expect(toDocumentLanguage(appLanguage)).toBe(expected);
});
it('follows every UI language change', () => {
TestBed.configureTestingModule({
imports: [HostComponent, TranslateModule.forRoot()],
});
const translate = TestBed.inject(TranslateService);
const document = TestBed.inject(DOCUMENT);
translate.setDefaultLang('en');
TestBed.createComponent(HostComponent);
expect(document.documentElement.lang).toBe('en');
translate.use('by');
expect(document.documentElement.lang).toBe('be');
translate.use('tr');
expect(document.documentElement.lang).toBe('tr');
});
it('follows the fallback language while no language is active', () => {
// Startup seeds the fallback from the stored hint, then resets it to
// English when no settings exist, without ever calling use().
TestBed.configureTestingModule({
imports: [
HostComponent,
TranslateModule.forRoot({ defaultLanguage: 'ru' }),
],
});
const translate = TestBed.inject(TranslateService);
const document = TestBed.inject(DOCUMENT);
TestBed.createComponent(HostComponent);
expect(document.documentElement.lang).toBe('ru');
translate.setDefaultLang('en');
expect(document.documentElement.lang).toBe('en');
translate.use('el');
translate.setDefaultLang('ru');
expect(document.documentElement.lang).toBe('el');
});
});
@@ -0,0 +1,46 @@
import { DOCUMENT } from '@angular/common';
import { DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { TranslateService } from '@ngx-translate/core';
import { merge } from 'rxjs';
/** UI language codes that are not valid BCP 47 tags. */
const DOCUMENT_LANGUAGE_OVERRIDES: Readonly<Record<string, string>> = {
by: 'be',
zhtw: 'zh-TW',
};
/**
* Maps an app language code to the value for `<html lang>`. The browser uses
* it for font fallback (Han variants), hyphenation and locale-aware
* `text-transform` (Turkish dotted İ, Greek accents in uppercase labels).
*/
export function toDocumentLanguage(appLanguage?: string | null): string {
const code = appLanguage?.trim();
if (!code) {
return 'en';
}
return DOCUMENT_LANGUAGE_OVERRIDES[code] ?? code;
}
/**
* Keeps `<html lang>` in step with the language the UI renders, whichever
* code path switches it (startup, settings). With no active language the UI
* renders the fallback: startup seeds it from the stored hint and then resets
* it to English when no settings exist, without an active-language change.
* Must run in an injection context.
*/
export function syncDocumentLanguage(): void {
const document = inject(DOCUMENT);
const translate = inject(TranslateService);
const apply = (): void => {
document.documentElement.lang = toDocumentLanguage(
translate.currentLang || translate.getDefaultLang()
);
};
apply();
merge(translate.onLangChange, translate.onDefaultLangChange)
.pipe(takeUntilDestroyed(inject(DestroyRef)))
.subscribe(apply);
}
@@ -40,7 +40,7 @@ import { UpdateChannelOption } from './settings.models';
'.app-update-channel { margin-top: 12px; }',
'.app-update-channel mat-form-field { width: 100%; max-width: 320px; }',
'.app-update-channel__note { display: block; margin-top: 4px; opacity: 0.75; font-size: 0.85em; }',
'.app-update-status__channel { align-self: flex-start; padding: 2px 9px; border-radius: 999px; font-size: 0.72rem; font-weight: 650; letter-spacing: 0.05em; text-transform: uppercase; color: var(--mat-sys-on-surface-variant); background: color-mix(in srgb, var(--mat-sys-on-surface) 9%, transparent); }',
'.app-update-status__channel { align-self: flex-start; padding: 2px 9px; border-radius: 999px; font-size: 0.72rem; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; color: var(--mat-sys-on-surface-variant); background: color-mix(in srgb, var(--mat-sys-on-surface) 9%, transparent); }',
'.app-update-status--stale strong, .app-update-status--stale .app-update-status__channel { opacity: 0.55; }',
],
})
@@ -55,7 +55,7 @@ li:first-child .settings-search-result {
.settings-search-result__label {
font-size: 0.95rem;
font-weight: 650;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--app-heading-color);
}
@@ -176,7 +176,7 @@
h4 {
margin: 0;
font-size: 0.95rem;
font-weight: 650;
font-weight: 600;
letter-spacing: -0.01em;
}
@@ -233,7 +233,7 @@
strong {
color: var(--app-body-color);
font-size: 0.9rem;
font-weight: 650;
font-weight: 600;
}
progress {
@@ -620,7 +620,7 @@
strong {
font-size: 0.92rem;
font-weight: 650;
font-weight: 600;
letter-spacing: -0.01em;
}
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>IPTVnator</title>
+12 -4
View File
@@ -15,17 +15,25 @@
@import 'material-design-icons-iconfont/dist/material-design-icons.css';
// ─── Bundled Fonts ────────────────────────────────────────────────────────────
// DM Sans ships only Latin glyphs, so Cyrillic and Greek text (ru, by, el)
// falls back to Roboto. Both load every weight on the UI scale
// (400/500/600/700, enforced by `pnpm run styles:font-weights:check`): a 600
// or 700 that finds nothing heavier than 500 gets Chromium's synthetic bold.
// Load whole weights, not the single-script files: those carry no
// unicode-range, so a Cyrillic-only 600 face would win the weight match for
// Latin text in `Roboto, …` stacks and push it onto the next family.
// JetBrains Mono stops at 500: its 600/700 faces would add more than the
// initial-bytes ratchet allows, so monospace text stays at 400/500.
@import '@fontsource/dm-sans/400.css';
@import '@fontsource/dm-sans/500.css';
@import '@fontsource/dm-sans/600.css';
@import '@fontsource/roboto/300.css';
@import '@fontsource/dm-sans/700.css';
@import '@fontsource/roboto/400.css';
@import '@fontsource/roboto/500.css';
@import '@fontsource/roboto/600.css';
@import '@fontsource/roboto/700.css';
@import '@fontsource/jetbrains-mono/400.css';
@import '@fontsource/jetbrains-mono/500.css';
@import '@fontsource/crimson-pro/400.css';
@import '@fontsource/crimson-pro/600.css';
@import '@fontsource/crimson-pro/700.css';
// ─── Shared Components ────────────────────────────────────────────────────────