mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
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:
1 parent
ab8460338a
commit
e8b181fcea
52 files changed
+11216
-97
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>IPTVnator</title>
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in new issue
Block a user