Compare commits
10 Commits
7bfb3d07fc
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
caae4d955d | ||
|
|
1738a109a7 | ||
|
|
91808a63f5 | ||
|
|
c441140872 | ||
|
|
fc0c1ed522 | ||
|
|
95a667cb98 | ||
|
|
77ed71cfb6 | ||
|
|
ed365db283 | ||
|
|
bd421954c5 | ||
|
|
8405f797d7 |
355
docs/superpowers/plans/2026-08-05-env-indicator.md
Normal file
355
docs/superpowers/plans/2026-08-05-env-indicator.md
Normal file
@@ -0,0 +1,355 @@
|
||||
# Umgebungs-Indikator (EnvBanner) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Ein schmaler Banner-Streifen erscheint app-weit oben, sobald `environment.production === false` (aktuell nur der lokale `ng serve`-Build), damit man Entwicklungsumgebung und echte App nie verwechselt.
|
||||
|
||||
**Architecture:** Neue Standalone-Komponente `EnvBanner` wird einmalig in `app.html` vor `<router-outlet />` eingebunden (single source of truth für alle Routen). `App` (Root-Komponente) bindet zusätzlich eine CSS-Custom-Property `--env-banner-height` auf ihr eigenes Host-Element, damit die drei `height:100dvh`-Layouts (Shell, Public-Team, Public-Player) die Banner-Höhe kompensieren können, ohne den kürzlich behobenen Doppel-Scrollbar-Bug erneut einzuführen.
|
||||
|
||||
**Tech Stack:** Angular 21 (Standalone Components, Signals, neue Control-Flow-Syntax), SCSS, Vitest.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Banner-Höhe ist eine feste Konstante `ENV_BANNER_HEIGHT_PX = 28` (Pixel), exportiert aus `env-banner.ts` und in `app.ts` wiederverwendet — an genau diesen zwei Stellen referenziert, nicht dupliziert.
|
||||
- Banner-Text ist exakt `⚠ Entwicklungsumgebung` — keine zusätzlichen technischen Details (API-URL, Build-Hash).
|
||||
- Banner-Farbe ist Amber/Orange (`#f4a300` Hintergrund, `#20251F` Text) — bewusst nicht das App-Grün (`--mat-sys-primary`).
|
||||
- Kein Dismiss/Schließen-Button.
|
||||
- Kein neues Feld in den drei Environment-Dateien — einzige Quelle ist das bereits vorhandene `environment.production` (siehe `docs/superpowers/specs/2026-08-05-env-indicator-design.md`, Abschnitt „Entscheidungen aus dem Brainstorming").
|
||||
- `EnvBanner` wird ausschließlich einmal in `app.html` eingebunden, nicht zusätzlich in Shell/Public-Seiten/Auth-Seiten.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `EnvBanner`-Komponente
|
||||
|
||||
**Files:**
|
||||
- Create: `myteamwallet_frontend_modern/src/app/shared/env-banner/env-banner.ts`
|
||||
- Create: `myteamwallet_frontend_modern/src/app/shared/env-banner/env-banner.html`
|
||||
- Create: `myteamwallet_frontend_modern/src/app/shared/env-banner/env-banner.scss`
|
||||
- Test: `myteamwallet_frontend_modern/src/app/shared/env-banner/env-banner.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `environment` aus `myteamwallet_frontend_modern/src/environments/environment.ts` (Feld `production: boolean`, per Angular `fileReplacements` je Build-Konfiguration ausgetauscht — bereits vorhanden, keine Änderung nötig).
|
||||
- Produces: `export class EnvBanner` (Selector `app-env-banner`, keine Inputs) und `export const ENV_BANNER_HEIGHT_PX = 28;` — beide werden in Task 2 von `app.ts` importiert.
|
||||
|
||||
- [ ] **Step 1: Fehlschlagenden Test schreiben**
|
||||
|
||||
Erstelle `myteamwallet_frontend_modern/src/app/shared/env-banner/env-banner.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { EnvBanner } from './env-banner';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
describe('EnvBanner', () => {
|
||||
const originalProduction = environment.production;
|
||||
|
||||
afterEach(() => {
|
||||
environment.production = originalProduction;
|
||||
});
|
||||
|
||||
it('shows the environment banner outside production', async () => {
|
||||
environment.production = false;
|
||||
await TestBed.configureTestingModule({ imports: [EnvBanner] }).compileComponents();
|
||||
const fixture = TestBed.createComponent(EnvBanner);
|
||||
fixture.detectChanges();
|
||||
|
||||
const element = fixture.nativeElement.querySelector('.env-banner');
|
||||
expect(element?.textContent).toContain('Entwicklungsumgebung');
|
||||
});
|
||||
|
||||
it('renders nothing in production', async () => {
|
||||
environment.production = true;
|
||||
await TestBed.configureTestingModule({ imports: [EnvBanner] }).compileComponents();
|
||||
const fixture = TestBed.createComponent(EnvBanner);
|
||||
fixture.detectChanges();
|
||||
|
||||
const element = fixture.nativeElement.querySelector('.env-banner');
|
||||
expect(element).toBeNull();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Test ausführen und Fehlschlag bestätigen**
|
||||
|
||||
Run: `cd myteamwallet_frontend_modern && npx ng test --watch=false --include='**/env-banner.spec.ts'`
|
||||
Expected: FAIL — `Cannot find module './env-banner'` (die Komponente existiert noch nicht).
|
||||
|
||||
- [ ] **Step 3: Komponente implementieren**
|
||||
|
||||
Erstelle `myteamwallet_frontend_modern/src/app/shared/env-banner/env-banner.ts`:
|
||||
|
||||
```ts
|
||||
import { Component } from '@angular/core';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
export const ENV_BANNER_HEIGHT_PX = 28;
|
||||
|
||||
@Component({
|
||||
selector: 'app-env-banner',
|
||||
templateUrl: './env-banner.html',
|
||||
styleUrl: './env-banner.scss',
|
||||
})
|
||||
export class EnvBanner {
|
||||
protected readonly showBanner = !environment.production;
|
||||
}
|
||||
```
|
||||
|
||||
Erstelle `myteamwallet_frontend_modern/src/app/shared/env-banner/env-banner.html`:
|
||||
|
||||
```html
|
||||
@if (showBanner) {
|
||||
<div class="env-banner" role="status">⚠ Entwicklungsumgebung</div>
|
||||
}
|
||||
```
|
||||
|
||||
Erstelle `myteamwallet_frontend_modern/src/app/shared/env-banner/env-banner.scss`:
|
||||
|
||||
```scss
|
||||
// Höhe muss mit ENV_BANNER_HEIGHT_PX in env-banner.ts übereinstimmen.
|
||||
.env-banner {
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f4a300;
|
||||
color: #20251f;
|
||||
font-weight: 700;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Test ausführen und Erfolg bestätigen**
|
||||
|
||||
Run: `cd myteamwallet_frontend_modern && npx ng test --watch=false --include='**/env-banner.spec.ts'`
|
||||
Expected: PASS — beide Tests grün.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd myteamwallet_frontend_modern
|
||||
git add src/app/shared/env-banner/
|
||||
git commit -m "feat: add EnvBanner component for non-production environments
|
||||
|
||||
Standalone component that renders a small banner whenever
|
||||
environment.production is false, so the local dev build is never
|
||||
mistaken for the real app."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Einbau in `App` (Root-Komponente) inkl. Höhen-Variable
|
||||
|
||||
**Files:**
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/app.ts`
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/app.html`
|
||||
- Create: `myteamwallet_frontend_modern/src/app/app.spec.ts` (existiert noch nicht)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `EnvBanner`, `ENV_BANNER_HEIGHT_PX` aus Task 1 (`myteamwallet_frontend_modern/src/app/shared/env-banner/env-banner.ts`); `environment` aus `myteamwallet_frontend_modern/src/environments/environment.ts`.
|
||||
- Produces: `<app-root>` setzt die Inline-Style-Custom-Property `--env-banner-height` (Wert inkl. `px`-Einheit, z. B. `"28px"` oder `"0px"`) auf seinem eigenen Host-Element. Task 3 liest diese Property per `var(--env-banner-height, 0px)`.
|
||||
|
||||
- [ ] **Step 1: Fehlschlagenden Test schreiben**
|
||||
|
||||
Erstelle `myteamwallet_frontend_modern/src/app/app.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { App } from './app';
|
||||
import { environment } from '../environments/environment';
|
||||
|
||||
describe('App', () => {
|
||||
const originalProduction = environment.production;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
environment.production = originalProduction;
|
||||
});
|
||||
|
||||
it('sets --env-banner-height to 0px and renders no banner in production', async () => {
|
||||
environment.production = true;
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter([])],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.style.getPropertyValue('--env-banner-height')).toBe('0px');
|
||||
expect(fixture.nativeElement.querySelector('.env-banner')).toBeNull();
|
||||
});
|
||||
|
||||
it('sets --env-banner-height to 28px and renders the banner outside production', async () => {
|
||||
environment.production = false;
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter([])],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.style.getPropertyValue('--env-banner-height')).toBe('28px');
|
||||
expect(fixture.nativeElement.querySelector('.env-banner')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Test ausführen und Fehlschlag bestätigen**
|
||||
|
||||
Run: `cd myteamwallet_frontend_modern && npx ng test --watch=false --include='**/app.spec.ts'`
|
||||
Expected: FAIL — `--env-banner-height` ist leer (`''`), kein `.env-banner`-Element vorhanden.
|
||||
|
||||
- [ ] **Step 3: `App` erweitern**
|
||||
|
||||
In `myteamwallet_frontend_modern/src/app/app.ts`, den bestehenden Inhalt ersetzen durch:
|
||||
|
||||
```ts
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { AuthApi } from './core/auth/auth-api';
|
||||
import { AuthStore } from './core/auth/auth-store';
|
||||
import { environment } from '../environments/environment';
|
||||
import { ENV_BANNER_HEIGHT_PX, EnvBanner } from './shared/env-banner/env-banner';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet, EnvBanner],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss',
|
||||
host: {
|
||||
'[style.--env-banner-height]': 'bannerHeight',
|
||||
},
|
||||
})
|
||||
export class App {
|
||||
private readonly authApi = inject(AuthApi);
|
||||
private readonly authStore = inject(AuthStore);
|
||||
protected readonly bannerHeight = `${environment.production ? 0 : ENV_BANNER_HEIGHT_PX}px`;
|
||||
|
||||
constructor() {
|
||||
if (this.authStore.token()) {
|
||||
this.authApi.me().subscribe({
|
||||
next: (response) => {
|
||||
const { token, ...user } = response;
|
||||
if (token) {
|
||||
this.authStore.setSession(token, user);
|
||||
} else {
|
||||
this.authStore.updateUser(user);
|
||||
}
|
||||
},
|
||||
error: () => undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(Nur `imports`, `host` und die neue `bannerHeight`-Property sind neu — Konstruktor-Logik unverändert übernommen.)
|
||||
|
||||
In `myteamwallet_frontend_modern/src/app/app.html`, den bestehenden Inhalt ersetzen durch:
|
||||
|
||||
```html
|
||||
<app-env-banner />
|
||||
<router-outlet />
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Test ausführen und Erfolg bestätigen**
|
||||
|
||||
Run: `cd myteamwallet_frontend_modern && npx ng test --watch=false --include='**/app.spec.ts'`
|
||||
Expected: PASS — beide Tests grün.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd myteamwallet_frontend_modern
|
||||
git add src/app/app.ts src/app/app.html src/app/app.spec.ts
|
||||
git commit -m "feat: mount EnvBanner app-wide and expose --env-banner-height
|
||||
|
||||
Renders the banner once at the app root so every route picks it up,
|
||||
and exposes its height as a CSS custom property so fixed-viewport
|
||||
layouts (Shell, public pages) can compensate for it."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Höhen-Kompensation in den `100dvh`-Layouts
|
||||
|
||||
**Files:**
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/core/layout/shell/shell.scss`
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/features/public-team/public-team.scss`
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/features/public-team/public-player.scss`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `--env-banner-height` Custom Property aus Task 2, gelesen per `var(--env-banner-height, 0px)` — der Fallback `0px` ist notwendig, damit `shell.spec.ts`, `public-team.spec.ts` und `public-player.spec.ts` (die diese Komponenten isoliert ohne `<app-root>`-Ancestor rendern) unverändert weiter grün bleiben.
|
||||
|
||||
Diese drei Dateien nutzen aktuell `height: 100dvh;` als feste Zusage „genau ein Bildschirm hoch" (siehe `docs/superpowers/plans/die-public-seite-kann-async-shamir.md` vom selben Tag zum Doppel-Scrollbar-Fix). Ohne Anpassung würde der neue Banner die Shell/Public-Seite um seine Höhe über den sichtbaren Bereich hinausschieben — derselbe Bugtyp wie der dort behobene.
|
||||
|
||||
- [ ] **Step 1: `shell.scss` anpassen**
|
||||
|
||||
In `myteamwallet_frontend_modern/src/app/core/layout/shell/shell.scss`, im `:host`-Block:
|
||||
|
||||
```scss
|
||||
// vorher: height: 100dvh;
|
||||
height: calc(100dvh - var(--env-banner-height, 0px));
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `public-team.scss` anpassen**
|
||||
|
||||
In `myteamwallet_frontend_modern/src/app/features/public-team/public-team.scss`, im `:host`-Block dieselbe Änderung:
|
||||
|
||||
```scss
|
||||
height: calc(100dvh - var(--env-banner-height, 0px));
|
||||
```
|
||||
|
||||
- [ ] **Step 3: `public-player.scss` anpassen**
|
||||
|
||||
In `myteamwallet_frontend_modern/src/app/features/public-team/public-player.scss`, im `:host`-Block dieselbe Änderung:
|
||||
|
||||
```scss
|
||||
height: calc(100dvh - var(--env-banner-height, 0px));
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Vollständige Test-Suite laufen lassen**
|
||||
|
||||
Run: `cd myteamwallet_frontend_modern && npx ng test --watch=false`
|
||||
Expected: alle Tests weiterhin PASS — reine CSS-Wertänderung, `shell.spec.ts`/`public-team.spec.ts`/`public-player.spec.ts` prüfen kein Layout und bleiben unberührt.
|
||||
|
||||
- [ ] **Step 5: Build laufen lassen**
|
||||
|
||||
Run: `cd myteamwallet_frontend_modern && npx ng build`
|
||||
Expected: Build erfolgreich (nur die bereits bekannte, unveränderte Bundle-Budget-Warnung).
|
||||
|
||||
- [ ] **Step 6: Manuelle Verifikation (kein automatisierter CSS-Layout-Test im Projekt vorhanden)**
|
||||
|
||||
`npm start`, im Browser (z. B. via Chrome DevTools) auf einer Shell-Seite mit langer Liste (`/team/:id/overview`) sowie auf `/t/:token` mit Inhalt prüfen:
|
||||
- Banner ist sichtbar, Header darunter bleibt beim Scrollen fix, Bottom-Nav ist vollständig sichtbar (nicht abgeschnitten).
|
||||
- `document.body.scrollHeight === document.body.clientHeight` (kein zusätzlicher Scrollbar auf `body`, wie beim vorherigen Fix verifiziert).
|
||||
- `ng build` (ohne `--configuration`, also production) und `ng build --configuration=container` zeigen keinen Banner (visuell/Bundle prüfen), `ng serve` (development) zeigt ihn.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
cd myteamwallet_frontend_modern
|
||||
git add src/app/core/layout/shell/shell.scss src/app/features/public-team/public-team.scss src/app/features/public-team/public-player.scss
|
||||
git commit -m "fix: compensate 100dvh layouts for the env banner's height
|
||||
|
||||
Shell and the public pages commit to exactly one viewport tall; without
|
||||
this, the new dev-environment banner would push their bottom edge (and
|
||||
Shell's bottom nav) past the visible viewport, the same overflow-leak
|
||||
bug fixed earlier today."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec-Abdeckung:** Komponente + Sichtbarkeitslogik (Task 1), App-weite Einbindung + Höhen-Variable (Task 2), Höhen-Kompensation der drei betroffenen Layouts (Task 3) — alle Abschnitte der Spec sind abgedeckt. Kein neues Environment-Feld (bewusst, siehe Spec).
|
||||
- **Typkonsistenz:** `ENV_BANNER_HEIGHT_PX` einmal in `env-banner.ts` definiert, in `app.ts` importiert und verwendet — keine Duplikation des Zahlenwerts außer dem dokumentierten Kommentar in `env-banner.scss`. `bannerHeight` liefert einen fertigen `px`-String statt eine Zahl mit `[style.prop.px]`-Unit-Suffix, da Angulars Unit-Suffix-Syntax für CSS-Custom-Properties (`--foo`) nicht zuverlässig dokumentiert/getestet ist — sicherer, den fertigen String zu binden.
|
||||
- **Scope:** Einzelne, in sich geschlossene Erweiterung; keine weitere Zerlegung nötig.
|
||||
113
docs/superpowers/specs/2026-08-05-env-indicator-design.md
Normal file
113
docs/superpowers/specs/2026-08-05-env-indicator-design.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Umgebungs-Indikator (Entwicklungsumgebung-Banner)
|
||||
|
||||
Status: approved
|
||||
Datum: 2026-08-05
|
||||
|
||||
## Kontext
|
||||
|
||||
Beim Arbeiten und Testen kann leicht unklar sein, ob man gerade in der lokalen
|
||||
Entwicklungsumgebung (`ng serve`, `environment.development.ts`) oder in der echten,
|
||||
produktiven App unterwegs ist — beide sehen optisch identisch aus. Ziel: ein visueller
|
||||
Indikator, der überall in der App sofort erkennbar macht, wenn man sich in der
|
||||
Entwicklungsumgebung befindet, damit man sich beim Testen nicht vertut.
|
||||
|
||||
## Entscheidungen aus dem Brainstorming
|
||||
|
||||
- **Betroffene Umgebungen**: Es gibt drei Angular-Build-Konfigurationen
|
||||
(`myteamwallet_frontend_modern/angular.json`): `production` (Standard,
|
||||
`environment.ts`, echte API auf myteamwallet.de), `development`
|
||||
(`environment.development.ts`, nur lokal via `ng serve`, `localhost:3999`) und
|
||||
`container` (`environment.container.ts`, `npm run build:container`). Der `container`-Build
|
||||
ist der reguläre Deploy-Weg der echten Produktion (z. B. self-hosted per Docker) — **kein**
|
||||
Staging-System — und setzt bereits selbst `production: true`. Der Indikator muss also nur
|
||||
`environment.production === false` erkennen; kein neues Feld in den Environment-Dateien nötig.
|
||||
- **Darstellung**: dünner Banner-Streifen ganz oben über der gesamten App (nicht nur im
|
||||
Header), warme Warnfarbe (Amber/Orange, bewusst nicht das App-Grün), Text „⚠
|
||||
Entwicklungsumgebung", zentriert, klein, kein Dismiss-Button (der Zweck ist ja gerade, ihn
|
||||
nicht wegzuklicken und zu vergessen).
|
||||
- **Inhalt**: nur der Umgebungsname, keine zusätzlichen technischen Details (API-URL o. ä.).
|
||||
- **Platzierung im Code**: einmalig in `app.html` vor `<router-outlet />`, statt in jeder
|
||||
Seite einzeln — automatisch auf jeder Route (Shell, Public-Seiten, Login, Register, Users,
|
||||
Logs, …) sichtbar, single source of truth.
|
||||
|
||||
## Architektur / Komponenten
|
||||
|
||||
### 1. Neue Komponente `EnvBanner`
|
||||
|
||||
**Ordner:** `myteamwallet_frontend_modern/src/app/shared/env-banner/`
|
||||
|
||||
Standalone-Komponente nach dem Muster bestehender Shared-Komponenten (`context-help`,
|
||||
`skeleton`) — kein Modul/Barrel, keine Inputs.
|
||||
|
||||
- `env-banner.ts`: importiert `environment` aus `../../../environments/environment` und
|
||||
exponiert `protected readonly showBanner = !environment.production;`. Exportiert außerdem
|
||||
die Konstante `export const ENV_BANNER_HEIGHT_PX = 28;` (wird von `App` für die
|
||||
Höhen-Kompensation wiederverwendet, siehe unten — ein einziger Ort für die Pixel-Zahl).
|
||||
- `env-banner.html`: `@if (showBanner) { <div class="env-banner" role="status">⚠
|
||||
Entwicklungsumgebung</div> }` — rendert in Produktion buchstäblich nichts (kein leeres
|
||||
DOM-Element).
|
||||
- `env-banner.scss`: `.env-banner { height: 28px; display:flex; align-items:center;
|
||||
justify-content:center; background: #f4a300; color:#20251F; font-weight:700; font-size:
|
||||
0.75rem; letter-spacing:0.04em; text-transform:uppercase; flex-shrink:0; }` (die `28px`
|
||||
müssen mit `ENV_BANNER_HEIGHT_PX` übereinstimmen — als Kommentar im SCSS vermerkt).
|
||||
- `env-banner.spec.ts`: rendert Text wenn `environment.production === false`, rendert nichts
|
||||
wenn `true` (Environment-Objekt im Test gemockt/überschrieben).
|
||||
|
||||
### 2. Einbau in `app.html` / `app.ts`
|
||||
|
||||
`app.html` bekommt vor `<router-outlet />` ein `<app-env-banner />`. `App` importiert
|
||||
`EnvBanner` in seine `imports`-Liste.
|
||||
|
||||
### 3. Höhen-Kompensation für `height: 100dvh`-Layouts
|
||||
|
||||
Der Banner nimmt echten Platz im normalen Fluss ein. Für Seiten, die nur `min-height:100dvh`
|
||||
nutzen und sich auf `body`s eigenen Scrollbar verlassen (Login, Register,
|
||||
Forgot/Reset-Password, Confirm-Email, Users, Logs), ist das unproblematisch — `body` gleicht
|
||||
das automatisch aus, kein Änderungsbedarf.
|
||||
|
||||
**Aber** `shell.scss`, `public-team.scss` und `public-player.scss` nutzen `height: 100dvh`
|
||||
als feste Zusage „genau ein Bildschirm hoch" (siehe
|
||||
`docs/superpowers/plans/`-Historie zum Doppel-Scrollbar-Fix vom selben Tag). Ohne Anpassung
|
||||
würde die Shell/Public-Seite exakt um die Banner-Höhe über den sichtbaren Bereich
|
||||
hinausragen (Bottom-Nav leicht abgeschnitten) — derselbe Bugtyp wie der kürzlich gefixte.
|
||||
|
||||
**Fix:** `App` (Root-Komponente) bindet eine CSS-Custom-Property auf ihr eigenes
|
||||
Host-Element. Host-Bindings werten Ausdrücke gegen die Komponenten-Instanz aus, daher als
|
||||
Instanz-Property vorhalten:
|
||||
|
||||
```ts
|
||||
host: {
|
||||
'[style.--env-banner-height.px]': 'bannerHeight',
|
||||
}
|
||||
// ...
|
||||
protected readonly bannerHeight = environment.production ? 0 : ENV_BANNER_HEIGHT_PX;
|
||||
```
|
||||
|
||||
Da `<app-root>` ein gemeinsamer Vorfahre von `EnvBanner` und allen Routen-Komponenten
|
||||
(Shell, Public-Seiten, …) ist, vererbt sich die Property automatisch nach unten. Die drei
|
||||
betroffenen SCSS-Dateien ändern:
|
||||
|
||||
```scss
|
||||
// vorher: height: 100dvh;
|
||||
height: calc(100dvh - var(--env-banner-height, 0px));
|
||||
```
|
||||
|
||||
In Produktion ist die Property `0px`, `calc(100dvh - 0px)` verhält sich identisch zu vorher
|
||||
— keine Verhaltensänderung außerhalb der Entwicklungsumgebung.
|
||||
|
||||
## Testing
|
||||
|
||||
- `env-banner.spec.ts` (neu): Sichtbarkeit abhängig von `environment.production`.
|
||||
- `app.spec.ts`: Erweiterung um Assertion, dass `--env-banner-height` korrekt `0px` bzw.
|
||||
`28px` auf dem Host gesetzt wird (je nach gemocktem `environment.production`).
|
||||
- Bestehende Tests (`shell.spec.ts`, `public-team.spec.ts`, `public-player.spec.ts`) bleiben
|
||||
unverändert grün — die `calc()`-Änderung ist rein visuell/CSS, keine Verhaltensänderung.
|
||||
- Manuelle Verifikation: `ng serve` (development) zeigt den Banner, `ng build` (production)
|
||||
und `ng build --configuration=container` zeigen ihn nicht; Shell/Public-Seiten scrollen mit
|
||||
Banner weiterhin korrekt ohne abgeschnittene Bottom-Nav (per Chrome DevTools nachprüfen).
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Kein neues `environmentName`-Feld in den Environment-Dateien (nicht nötig, siehe oben).
|
||||
- Kein Dismiss/Ausblenden des Banners.
|
||||
- Keine Anzeige zusätzlicher technischer Details (API-URL, Build-Hash) im Banner-Text.
|
||||
@@ -1 +1,2 @@
|
||||
<app-env-banner />
|
||||
<router-outlet />
|
||||
|
||||
@@ -5,8 +5,10 @@ import { of } from 'rxjs';
|
||||
import { App } from './app';
|
||||
import { AuthApi } from './core/auth/auth-api';
|
||||
import { AuthStore } from './core/auth/auth-store';
|
||||
import { environment } from '../environments/environment';
|
||||
|
||||
describe('App', () => {
|
||||
const originalProduction = environment.production;
|
||||
const token = signal<string | null>(null);
|
||||
const updateUser = vi.fn();
|
||||
const meResponse = signal<Record<string, unknown>>({
|
||||
@@ -34,6 +36,10 @@ describe('App', () => {
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
environment.production = originalProduction;
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
expect(fixture.componentInstance).toBeTruthy();
|
||||
@@ -73,4 +79,22 @@ describe('App', () => {
|
||||
});
|
||||
expect(updateUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets --env-banner-height to 0px and renders no banner in production', () => {
|
||||
environment.production = true;
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.style.getPropertyValue('--env-banner-height')).toBe('0px');
|
||||
expect(fixture.nativeElement.querySelector('.env-banner')).toBeNull();
|
||||
});
|
||||
|
||||
it('sets --env-banner-height to 28px and renders the banner outside production', () => {
|
||||
environment.production = false;
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.style.getPropertyValue('--env-banner-height')).toBe('28px');
|
||||
expect(fixture.nativeElement.querySelector('.env-banner')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,16 +2,22 @@ import { Component, inject } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { AuthApi } from './core/auth/auth-api';
|
||||
import { AuthStore } from './core/auth/auth-store';
|
||||
import { environment } from '../environments/environment';
|
||||
import { ENV_BANNER_HEIGHT_PX, EnvBanner } from './shared/env-banner/env-banner';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet],
|
||||
imports: [RouterOutlet, EnvBanner],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss',
|
||||
host: {
|
||||
'[style.--env-banner-height]': 'bannerHeight',
|
||||
},
|
||||
})
|
||||
export class App {
|
||||
private readonly authApi = inject(AuthApi);
|
||||
private readonly authStore = inject(AuthStore);
|
||||
protected readonly bannerHeight = `${environment.production ? 0 : ENV_BANNER_HEIGHT_PX}px`;
|
||||
|
||||
constructor() {
|
||||
if (this.authStore.token()) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
height: calc(100dvh - var(--env-banner-height, 0px));
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -17,6 +18,7 @@
|
||||
|
||||
.shell-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.auth-page {
|
||||
min-height: 100dvh;
|
||||
min-height: calc(100dvh - var(--env-banner-height, 0px));
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.auth-page {
|
||||
min-height: 100dvh;
|
||||
min-height: calc(100dvh - var(--env-banner-height, 0px));
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100dvh;
|
||||
min-height: calc(100dvh - var(--env-banner-height, 0px));
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.auth-page {
|
||||
min-height: 100dvh;
|
||||
min-height: calc(100dvh - var(--env-banner-height, 0px));
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.auth-page {
|
||||
min-height: 100dvh;
|
||||
min-height: calc(100dvh - var(--env-banner-height, 0px));
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100dvh;
|
||||
min-height: calc(100dvh - var(--env-banner-height, 0px));
|
||||
background: var(--mat-sys-surface);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,25 @@
|
||||
><a mat-stroked-button routerLink="/auth/login"><mat-icon>login</mat-icon>Anmelden</a>
|
||||
</header>
|
||||
<main>
|
||||
<div class="page">
|
||||
<a mat-button [routerLink]="['/t', token]"><mat-icon>arrow_back</mat-icon>Zur Teamübersicht</a>
|
||||
<header class="page-title">
|
||||
<p class="eyebrow">Öffentliche Ansicht</p>
|
||||
<h1>{{ player()?.firstName }} {{ player()?.lastName }}</h1>
|
||||
<p>Die letzten Buchungen dieses Mitglieds.</p>
|
||||
@if (loading()) {
|
||||
<app-skeleton width="100px" height="12px" />
|
||||
<app-skeleton width="45%" height="40px" />
|
||||
<app-skeleton width="70%" height="14px" />
|
||||
} @else {
|
||||
<p class="eyebrow">Öffentliche Ansicht</p>
|
||||
<h1>{{ player()?.firstName }} {{ player()?.lastName }}</h1>
|
||||
<p>Die letzten Buchungen dieses Mitglieds.</p>
|
||||
}
|
||||
</header>
|
||||
@if (loading()) {
|
||||
<div class="state"><mat-spinner diameter="40" /></div>
|
||||
<div role="status" aria-label="Verlauf wird geladen" class="transactions">
|
||||
@for (row of skeletonRows; track row) {
|
||||
<app-skeleton height="60px" radius="17px" />
|
||||
}
|
||||
</div>
|
||||
} @else if (notFound()) {
|
||||
<div class="state"><mat-icon>search_off</mat-icon><span>Verlauf nicht gefunden.</span></div>
|
||||
} @else if (transactions().length === 0) {
|
||||
@@ -41,4 +52,5 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100dvh - var(--env-banner-height, 0px));
|
||||
overflow: hidden;
|
||||
// background: color-mix(in srgb, var(--mat-sys-primary-container) 18%, var(--mat-sys-surface));
|
||||
}
|
||||
.public-header {
|
||||
flex-shrink: 0;
|
||||
height: 64px;
|
||||
padding: 0 max(20px, calc((100vw - 900px) / 2));
|
||||
display: flex;
|
||||
@@ -22,6 +25,11 @@
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.page {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 24px 64px;
|
||||
@@ -37,6 +45,9 @@ main {
|
||||
.page-title p {
|
||||
margin-top: 0;
|
||||
}
|
||||
.page-title app-skeleton + app-skeleton {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.eyebrow {
|
||||
color: var(--mat-sys-primary);
|
||||
font-size: 0.75rem;
|
||||
@@ -83,7 +94,7 @@ main {
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
main {
|
||||
.page {
|
||||
padding: 22px 16px 48px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@ import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { PublicTeamApi } from '../../core/team/public-team-api';
|
||||
import { PlayerTransaction } from '../../models/transaction.model';
|
||||
import { PublicPlayer as PublicPlayerModel } from '../../models/public-access.model';
|
||||
import { Skeleton } from '../../shared/skeleton/skeleton';
|
||||
import { TransactionAmount } from '../../shared/transaction-amount/transaction-amount';
|
||||
|
||||
registerLocaleData(localeDe);
|
||||
@@ -21,7 +21,7 @@ registerLocaleData(localeDe);
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
Skeleton,
|
||||
TransactionAmount,
|
||||
],
|
||||
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
|
||||
@@ -36,6 +36,7 @@ export class PublicPlayer {
|
||||
protected readonly transactions = signal<PlayerTransaction[]>([]);
|
||||
protected readonly loading = signal(true);
|
||||
protected readonly notFound = signal(false);
|
||||
protected readonly skeletonRows = [0, 1, 2, 3, 4];
|
||||
|
||||
constructor() {
|
||||
const playerId = Number(this.route.snapshot.paramMap.get('playerId'));
|
||||
|
||||
@@ -5,8 +5,39 @@
|
||||
<a mat-stroked-button routerLink="/auth/login"><mat-icon>login</mat-icon>Anmelden</a>
|
||||
</header>
|
||||
<main>
|
||||
<div class="page">
|
||||
@if (loading()) {
|
||||
<div class="state"><mat-spinner diameter="42" /><span>Team wird geladen …</span></div>
|
||||
<div role="status" aria-label="Team wird geladen">
|
||||
<section class="hero">
|
||||
<app-skeleton width="160px" height="12px" />
|
||||
<app-skeleton width="45%" height="44px" />
|
||||
<div class="balance-grid">
|
||||
<app-skeleton height="94px" radius="20px" />
|
||||
<app-skeleton height="94px" radius="20px" />
|
||||
<app-skeleton height="94px" radius="20px" />
|
||||
</div>
|
||||
</section>
|
||||
<section class="content-grid">
|
||||
<div>
|
||||
<app-skeleton width="90px" height="12px" />
|
||||
<app-skeleton width="140px" height="26px" />
|
||||
<div class="list-skeleton">
|
||||
@for (row of skeletonMemberRows; track row) {
|
||||
<app-skeleton height="54px" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<aside>
|
||||
<app-skeleton width="110px" height="12px" />
|
||||
<app-skeleton width="160px" height="26px" />
|
||||
<div class="list-skeleton">
|
||||
@for (row of skeletonPenaltyRows; track row) {
|
||||
<app-skeleton height="60px" radius="16px" />
|
||||
}
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
} @else if (notFound() || !team()) {
|
||||
<div class="state">
|
||||
<mat-icon>search_off</mat-icon>
|
||||
@@ -73,4 +104,5 @@
|
||||
</aside>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100dvh - var(--env-banner-height, 0px));
|
||||
overflow: hidden;
|
||||
// background: color-mix(in srgb, var(--mat-sys-primary-container) 18%, var(--mat-sys-surface));
|
||||
}
|
||||
.public-header {
|
||||
flex-shrink: 0;
|
||||
height: 64px;
|
||||
padding: 0 max(20px, calc((100vw - 1180px) / 2));
|
||||
display: flex;
|
||||
@@ -22,6 +25,11 @@
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.page {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 24px 64px;
|
||||
@@ -39,6 +47,13 @@ main {
|
||||
text-transform: uppercase;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.hero app-skeleton + app-skeleton {
|
||||
margin: 8px 0 20px;
|
||||
}
|
||||
.content-grid > div > app-skeleton,
|
||||
.content-grid > aside > app-skeleton {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.balance-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
@@ -81,6 +96,10 @@ aside h2 {
|
||||
.search {
|
||||
width: 100%;
|
||||
}
|
||||
.list-skeleton {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.player-list {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--mat-sys-outline-variant);
|
||||
@@ -135,7 +154,7 @@ aside h2 {
|
||||
.content-grid {
|
||||
gap: 32px;
|
||||
}
|
||||
main {
|
||||
.page {
|
||||
padding: 28px 16px 48px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ import { MatCardModule } from '@angular/material/card';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { PublicTeamApi } from '../../core/team/public-team-api';
|
||||
import { Penalty } from '../../models/penalty.model';
|
||||
import { PublicTeamOverview } from '../../models/public-access.model';
|
||||
import { Skeleton } from '../../shared/skeleton/skeleton';
|
||||
|
||||
registerLocaleData(localeDe);
|
||||
|
||||
@@ -24,7 +24,7 @@ registerLocaleData(localeDe);
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
Skeleton,
|
||||
],
|
||||
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
|
||||
templateUrl: './public-team.html',
|
||||
@@ -38,6 +38,8 @@ export class PublicTeam {
|
||||
protected readonly loading = signal(true);
|
||||
protected readonly notFound = signal(false);
|
||||
protected readonly search = signal('');
|
||||
protected readonly skeletonMemberRows = [0, 1, 2, 3, 4];
|
||||
protected readonly skeletonPenaltyRows = [0, 1, 2];
|
||||
protected readonly players = computed(() => {
|
||||
const query = this.search().trim().toLocaleLowerCase('de');
|
||||
return (this.team()?.players ?? [])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100dvh;
|
||||
min-height: calc(100dvh - var(--env-banner-height, 0px));
|
||||
background: var(--mat-sys-surface);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
@if (showBanner) {
|
||||
<div class="env-banner" role="status">⚠ Entwicklungsumgebung</div>
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Höhe muss mit ENV_BANNER_HEIGHT_PX in env-banner.ts übereinstimmen.
|
||||
.env-banner {
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f4a300;
|
||||
color: #20251f;
|
||||
font-weight: 700;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { EnvBanner } from './env-banner';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
describe('EnvBanner', () => {
|
||||
const originalProduction = environment.production;
|
||||
|
||||
afterEach(() => {
|
||||
environment.production = originalProduction;
|
||||
});
|
||||
|
||||
it('shows the environment banner outside production', async () => {
|
||||
environment.production = false;
|
||||
await TestBed.configureTestingModule({ imports: [EnvBanner] }).compileComponents();
|
||||
const fixture = TestBed.createComponent(EnvBanner);
|
||||
fixture.detectChanges();
|
||||
|
||||
const element = fixture.nativeElement.querySelector('.env-banner');
|
||||
expect(element?.textContent).toContain('Entwicklungsumgebung');
|
||||
});
|
||||
|
||||
it('renders nothing in production', async () => {
|
||||
environment.production = true;
|
||||
await TestBed.configureTestingModule({ imports: [EnvBanner] }).compileComponents();
|
||||
const fixture = TestBed.createComponent(EnvBanner);
|
||||
fixture.detectChanges();
|
||||
|
||||
const element = fixture.nativeElement.querySelector('.env-banner');
|
||||
expect(element).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
export const ENV_BANNER_HEIGHT_PX = 28;
|
||||
|
||||
@Component({
|
||||
selector: 'app-env-banner',
|
||||
templateUrl: './env-banner.html',
|
||||
styleUrl: './env-banner.scss',
|
||||
})
|
||||
export class EnvBanner {
|
||||
protected readonly showBanner = !environment.production;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<div
|
||||
class="skeleton"
|
||||
aria-hidden="true"
|
||||
[style.width]="width()"
|
||||
[style.height]="height()"
|
||||
[style.border-radius]="radius()"
|
||||
></div>
|
||||
31
myteamwallet_frontend_modern/src/app/shared/skeleton/skeleton.scss
vendored
Normal file
31
myteamwallet_frontend_modern/src/app/shared/skeleton/skeleton.scss
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
display: block;
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
var(--mat-sys-surface-container) 30%,
|
||||
var(--mat-sys-surface-container-high) 50%,
|
||||
var(--mat-sys-surface-container) 70%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton {
|
||||
animation: none;
|
||||
background: var(--mat-sys-surface-container-high);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Skeleton } from './skeleton';
|
||||
|
||||
describe('Skeleton', () => {
|
||||
it('renders a placeholder block sized from its inputs and hidden from screen readers', async () => {
|
||||
await TestBed.configureTestingModule({ imports: [Skeleton] }).compileComponents();
|
||||
const fixture = TestBed.createComponent(Skeleton);
|
||||
fixture.componentRef.setInput('width', '60%');
|
||||
fixture.componentRef.setInput('height', '24px');
|
||||
fixture.componentRef.setInput('radius', '12px');
|
||||
fixture.detectChanges();
|
||||
|
||||
const element = fixture.nativeElement.querySelector('.skeleton');
|
||||
expect(element.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(element.style.width).toBe('60%');
|
||||
expect(element.style.height).toBe('24px');
|
||||
expect(element.style.borderRadius).toBe('12px');
|
||||
});
|
||||
|
||||
it('falls back to sensible default dimensions', async () => {
|
||||
await TestBed.configureTestingModule({ imports: [Skeleton] }).compileComponents();
|
||||
const fixture = TestBed.createComponent(Skeleton);
|
||||
fixture.detectChanges();
|
||||
|
||||
const element = fixture.nativeElement.querySelector('.skeleton');
|
||||
expect(element.style.width).toBe('100%');
|
||||
expect(element.style.height).toBe('16px');
|
||||
expect(element.style.borderRadius).toBe('8px');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-skeleton',
|
||||
templateUrl: './skeleton.html',
|
||||
styleUrl: './skeleton.scss',
|
||||
})
|
||||
export class Skeleton {
|
||||
readonly width = input('100%');
|
||||
readonly height = input('16px');
|
||||
readonly radius = input('8px');
|
||||
}
|
||||
@@ -36,6 +36,7 @@ body {
|
||||
// Reset the user agent margin.
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.shell-notification-menu {
|
||||
|
||||
Reference in New Issue
Block a user