Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions app/src/sections/admin/diagnostics/DiagnosticDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,17 @@
// the admin context.

import CloseIcon from '@mui/icons-material/Close';
import { Alert, Box, CircularProgress, Dialog, Divider, IconButton, Typography } from '@mui/material';
import {
Alert,
Box,
CircularProgress,
Dialog,
Divider,
IconButton,
Typography,
useMediaQuery,
useTheme,
} from '@mui/material';
import { useState } from 'react';

import { WrittenGlyph } from '@/components/WrittenGlyph';
Expand Down Expand Up @@ -41,6 +51,11 @@ function Section({ title, intro, children }: { title: string; intro: string; chi

export function DiagnosticDialog() {
const { diagnoseGlyph, closeDiagnose, glyphsByKey, cropCacheBust, sourceId } = useAdmin();
// Same rule as the setup wizard: below `md` the 32 px margins on either side
// are width the processing stages cannot spare, so the modal takes the whole
// screen instead.
const theme = useTheme();
const compact = useMediaQuery(theme.breakpoints.down('md'));
const open = diagnoseGlyph != null;
const glyphKey = diagnoseGlyph ?? '';
const known = glyphKey ? knownGlyph(glyphKey) : null;
Expand All @@ -60,7 +75,14 @@ export function DiagnosticDialog() {
}

return (
<Dialog open={open} onClose={closeDiagnose} fullWidth maxWidth="xl" slotProps={{ paper: { sx: { height: '92vh' } } }}>
<Dialog
open={open}
onClose={closeDiagnose}
fullScreen={compact}
fullWidth
maxWidth="xl"
slotProps={{ paper: { sx: { height: compact ? '100%' : '92vh' } } }}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 2, pt: 1.5, pb: 1 }}>
<Typography variant="h6" sx={{ flex: 1 }}>
{de.admin.diagnostics.title} {known?.label ?? glyphKey}
Expand Down
4 changes: 2 additions & 2 deletions app/src/sections/admin/diagnostics/DiagnosticView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const COL_H = 360;

export function DiagnosticView({ glyphKey, cropCacheBust, colWidth, colHeight, onData }: Props) {
const { sourceId } = useAdmin();
const COL_W = useColumnWidth(colWidth);
const [colContainer, COL_W] = useColumnWidth(colWidth);
const COL_H_PX = colHeight ?? COL_H;
const [data, setData] = useState<DiagnosticData | null>(null);
// Starts true: the first render is already waiting for the request the effect
Expand Down Expand Up @@ -135,7 +135,7 @@ export function DiagnosticView({ glyphKey, cropCacheBust, colWidth, colHeight, o
const tplViewBox = `${tplX0} ${-tpl.ascender - 0.3} ${tplX1 - tplX0} ${tplViewH + 0.6}`;

return (
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<Box ref={colContainer} sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
{/* Column 1 — Crop pur */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, maxWidth: Math.max(cropDisplayW, 180) }}>
<Typography variant="caption" color="text.secondary">
Expand Down
4 changes: 2 additions & 2 deletions app/src/sections/admin/diagnostics/FitView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ function polylineSegments(pts: Array<[number, number]>, starts?: number[]): Arra

export function FitView({ glyphKey, cropCacheBust, colWidth, colHeight }: Props) {
const { sourceId } = useAdmin();
const COL_W = useColumnWidth(colWidth);
const [colContainer, COL_W] = useColumnWidth(colWidth);
const COL_H_PX = colHeight ?? COL_H;
const [data, setData] = useState<FitData | null>(null);
// Starts true: the first render already waits for the fit the effect below
Expand Down Expand Up @@ -128,7 +128,7 @@ export function FitView({ glyphKey, cropCacheBust, colWidth, colHeight }: Props)
const m = data.fit;

return (
<Stack spacing={1.5}>
<Stack ref={colContainer} spacing={1.5}>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'flex-start' }}>
{/* Overlay: crop + skeleton + canonical (grey) + fit (red) */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
Expand Down
67 changes: 48 additions & 19 deletions app/src/sections/admin/diagnostics/useColumnWidth.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,52 @@
import { useEffect, useState } from 'react';
import { useLayoutEffect, useState } from 'react';

// Cap the column width to the viewport so the three columns wrap and fit on
// narrow phones instead of forcing horizontal scroll. `cap` is the desktop
// ceiling (320 by default; the Diagnose modal passes a larger one).
export function clampColumnWidth(viewport: number, cap = 320) {
// Stay positive even on absurdly narrow viewports so the derived scale and
// SVG/image width/height never go to 0 or negative.
return Math.max(120, Math.min(cap, viewport - 64));
// Size the diagnostic columns from the box they actually sit in, not from the
// window. Their one mount is the Diagnose modal, whose paper is 64 px narrower
// than the viewport and pads another 32 px inside — a window-derived width was
// too wide by exactly that much, so at 390 px the modal scrolled sideways and
// clipped the crop (author report on PR #533).
//
// `cap` is the desktop ceiling (320 by default; the Diagnose modal passes a
// larger one). The 120 px floor keeps the derived scale and the SVG/image
// width/height positive even in an absurdly narrow box.
export function clampColumnWidth(available: number, cap = 320) {
return Math.max(120, Math.min(cap, available));
}

export function useColumnWidth(cap?: number) {
const [w, setW] = useState(() => clampColumnWidth(typeof window !== 'undefined' ? window.innerWidth : 360, cap));
useEffect(() => {
const onResize = () => setW(clampColumnWidth(window.innerWidth, cap));
// Recompute immediately so a changed cap applies without waiting for the
// next resize event.
onResize();
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [cap]);
return w;
// Only for the frames before the container is measured (and for an environment
// without layout at all): the window minus the page gutters, which is what this
// hook used to return for good.
function viewportEstimate() {
return typeof window === 'undefined' ? 360 : window.innerWidth - 64;
}

/**
* Returns `[containerRef, columnWidth]`. Put the ref on the element whose width
* the columns have to fit into.
*/
export function useColumnWidth(cap?: number): [(el: HTMLElement | null) => void, number] {
// A callback ref rather than `useRef`: both views show a spinner first and
// mount their container only once the payload lands, and state is what makes
// that later mount re-run the measurement.
const [container, setContainer] = useState<HTMLElement | null>(null);
const [width, setWidth] = useState(() => clampColumnWidth(viewportEstimate(), cap));

useLayoutEffect(() => {
if (!container) return;
const measure = () => setWidth(clampColumnWidth(container.clientWidth, cap));
measure();
// The container is block-level, so its width never follows the columns it
// holds — observing it cannot loop. It also catches the width changes no
// window resize reports: the modal's own scrollbar appearing, or a
// breakpoint turning the dialog full-screen.
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}
const observer = new ResizeObserver(measure);
observer.observe(container);
return () => observer.disconnect();
}, [container, cap]);

return [setContainer, width];
}
40 changes: 40 additions & 0 deletions app/src/sections/admin/quality/labelColumn.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// The label column of the penalty breakdown is sized from the labels
// themselves. Before this it was a hard-coded 78 px — about nine characters —
// and „Deckungslücke" ran 32 px past its box straight over its own bar. Pinning
// the measurement keeps a renamed category from silently reintroducing that:
// the width has to follow the longest label, not a number somebody eyeballed.

import { describe, expect, it } from 'vitest';

import { de } from '@/locales/admin';
import { labelColumnChars } from './labelColumn';

const LONGEST = 'Deckungslücke'; // 13 characters, precomposed ü

describe('labelColumnChars', () => {
it('measures the longest label, not the first or the last', () => {
expect(labelColumnChars(['Ecken', LONGEST, 'Glätte'])).toBe(13);
});

it('counts a combining umlaut as one character', () => {
// The monospace face advances one cell per character; counting the
// combining mark separately would reserve a cell that is never advanced.
const decomposed = 'Deckungslücke';
expect(decomposed.length).toBe(14);
expect(labelColumnChars([decomposed])).toBe(13);
});

it('survives an empty set rather than returning -Infinity', () => {
expect(labelColumnChars([])).toBe(0);
});

it('fits every category label of the naturalness metric', () => {
// The live check: the column is measured over exactly these strings, so a
// new or renamed category has to stay within what the width is derived
// from. Kurrent carries no `components`, so this set is the whole surface.
const labels = Object.values(de.wizard.optimize.cat);
const width = labelColumnChars(labels);
for (const label of labels) expect(label.normalize('NFC').length).toBeLessThanOrEqual(width);
expect(width).toBe(LONGEST.length);
});
});
10 changes: 10 additions & 0 deletions app/src/sections/admin/quality/labelColumn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// How wide the label column of a penalty breakdown has to be — its own module
// because `scoreParts.tsx` may only export components (react-refresh).

/** Characters in the longest of `labels` — the width the label column needs. */
export function labelColumnChars(labels: readonly string[]): number {
// NFC first: „Deckungslücke" is 13 characters with a precomposed ü and 14
// with a combining one, and only the precomposed count matches what the
// monospace face actually advances.
return labels.reduce((widest, label) => Math.max(widest, label.normalize('NFC').length), 0);
}
17 changes: 16 additions & 1 deletion app/src/sections/admin/quality/scoreParts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Box, Chip, LinearProgress, Stack, Tooltip, Typography } from '@mui/mate

import type { QualityData } from '@/lib/api';
import { de } from '@/locales/admin';
import { labelColumnChars } from './labelColumn';

// Module-private on purpose: a score reaches the screen through ScoreChip, so
// there is exactly one place where a threshold can be changed.
Expand Down Expand Up @@ -49,6 +50,20 @@ const NOTABLE_PENALTY = 0.15; // mirrors glyphlab's _SCORE_HI — a deduction wo
const PENALTY_EPS = 0.005; // below this a category is effectively perfect / not applicable
const BAR_FULL_PENALTY = 0.3; // penalty mapped to a full bar (penalties rarely exceed this)

// The bar starts where the LONGEST label ends. A fixed 78 px column fitted
// about nine characters, so „Deckungslücke" ran 32 px past its box and painted
// over its own bar (author report on PR #533). The labels are set in a
// monospace face, so one `ch` is one character and the widest label's character
// count IS the column width — measured from the strings themselves, so a
// renamed category re-measures itself. The extra pixel absorbs the subpixel
// rounding of a fractional advance: the box must round UP, or the longest label
// clips by a pixel.
//
// Sized over ALL categories, not just the rows on screen: a category below
// `PENALTY_EPS` drops out, and a column that shrank with it would put the two
// cards' bars at different x.
const LABEL_COL_WIDTH = `calc(${labelColumnChars(COMPONENT_KEYS.map((key) => de.wizard.optimize.cat[key]))}ch + 1px)`;

function penaltyColor(val: number): 'error' | 'warning' | 'primary' {
if (val >= 0.25) return 'error';
if (val >= NOTABLE_PENALTY) return 'warning';
Expand Down Expand Up @@ -99,7 +114,7 @@ export function ScoreBreakdown({
<Typography
variant="caption"
tabIndex={0}
sx={{ width: 78, flexShrink: 0, fontFamily: 'monospace', cursor: 'help' }}
sx={{ width: LABEL_COL_WIDTH, flexShrink: 0, fontFamily: 'monospace', cursor: 'help' }}
>
{t.cat[r.key]}
</Typography>
Expand Down
25 changes: 25 additions & 0 deletions changelog.d/diagnose-modal-narrow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
### Fixed

- **The penalty breakdown no longer writes „Deckungslücke" across its own
bar.** The label column was a hard-coded 78 px — about nine characters of the
monospace face it is set in — so the longest category name of the Sütterlin
naturalness metric ran 32 px past its box and painted over the bar it belongs
to, at every viewport. The column is measured from the labels themselves now
(`labelColumnChars`, in `ch` because the face is monospace, and over the whole
category set rather than the rows that happen to clear `PENALTY_EPS`), so the
bars stay aligned across both cards and a renamed category re-measures itself
instead of clipping. Pinned by `labelColumn.test.ts`. Only the Sütterlin
metric reaches this surface at all: the Kurrent metric returns no
`components`, so its cards carry no per-category breakdown.

- **The Diagnose modal stops scrolling sideways on a phone.** Its
processing-stage columns were sized from `window.innerWidth − 64`, which is
the page's gutter and not the modal's: the paper is a further 32 px narrower
and pads another 32 px inside, so at 390 px every column stood 47 px wider
than the box holding it, the dialog scrolled horizontally and the crop was cut
off at the right edge. `useColumnWidth` now measures the container it is
handed instead of the window — through a `ResizeObserver`, so a breakpoint
change or the modal's own scrollbar re-measures too — and the dialog goes
full-screen below `md` like the setup wizard, which buys back the 64 px of
margin. Desktop is unchanged (420 px columns at 1440 px). The two score cards
were already stacking at that width; they were not the cause.
14 changes: 10 additions & 4 deletions docs/reference/frontend-stack.md
Original file line number Diff line number Diff line change
Expand Up @@ -1103,8 +1103,11 @@ Wire-Typen handsynchron zu `api/schemas.py`) · `domain/glyphs.ts`
- `sections/admin/setup-wizard/` — `SetupWizard` (Dialog-Shell) + `useWizard`
(State + Server-Mutationen) + `useCropView` (Crop-Viewport) + `WizardCanvas`
+ `steps/{Mask,Lineatur,Slant,Trace,Overview}Step`. Einzige Autoren-Fläche.
- `sections/admin/diagnostics/` — `DiagnosticDialog` (3-Spalten + M4-Fit),
`DiagnosticView`/`FitView`.
- `sections/admin/diagnostics/` — `DiagnosticDialog` (3-Spalten + M4-Fit;
unter `md` Vollbild wie der Wizard), `DiagnosticView`/`FitView`. Ihre
Spaltenbreite kommt aus `useColumnWidth`, das den CONTAINER misst
(ResizeObserver) und nicht das Fenster — die Fensterbreite kennt die
Ränder des Dialogs nicht und ließ das Modal bei 390 px quer scrollen.
- `sections/admin/shell/` — die Werkbank-Hülle, die alle drei Ansichten
teilen: `AdminHeader` (drei Bereiche + Vorlagen-Chip + Korb-Badge),
`StartView` (`/admin`, die Vorlagen-Auswahl), `LetterPicker`
Expand Down Expand Up @@ -1204,8 +1207,11 @@ Wire-Typen handsynchron zu `api/schemas.py`) · `domain/glyphs.ts`
Aufschlüsselung je Kategorie; das liegt AUSSERHALB des Wizards, damit die
Wizard-Vorschau, das Diagnose-Modal (das die Aufschlüsselung dadurch
bekam, die es nie zeigte, obwohl sein Payload sie immer trug) und die
Buchstaben-Übersicht dieselbe Zahl auf dieselbe Weise lesen;
`setup-wizard/steps/previewParts.tsx` behält nur noch die
Buchstaben-Übersicht dieselbe Zahl auf dieselbe Weise lesen; die Breite der
Beschriftungsspalte misst das Nachbarmodul `quality/labelColumn.ts`
(`labelColumnChars`, in `ch`) aus den Bezeichnern selbst — eigenes Modul,
weil `scoreParts.tsx` unter der react-refresh-Regel nur Komponenten
exportieren darf. `setup-wizard/steps/previewParts.tsx` behält nur noch die
Silhouetten-Überlagerung. Der Tooltip des Chips sagt ausdrücklich, dass
die gespeicherte Zahl der Score ZUM ZEITPUNKT DES AUTORIERENS ist und
keine Neubewertung mit der heutigen Metrik.
Expand Down
Loading