Compare commits

..

1 Commits

Author SHA1 Message Date
Maurice 4051af58a7 fix(studio): a rail label wraps between words, never inside one
The rail gave its labels 48px and let them break anywhere, so Elements read Element and s, and nine languages broke a word at the default text size. Labels wrap only between words now, and the rail grows to its longest word from 64px.
2026-10-25 09:46:40 +01:00
4 changed files with 71 additions and 20 deletions
@@ -19,7 +19,8 @@ import type { StudioUploader } from './studioUpload'
* has a handful of completely different kinds of thing you reach for (which
* page, what from the journey, what arrangement, what to draw), and putting
* them behind tabs in a single 200px column would make each of them cramped.
* An icon rail costs 64px and gives the panel the room a photo browser needs.
* An icon rail costs 64px, a little more where a section's name holds a longer
* word, and gives the panel the room a photo browser needs.
*
* Which section is open is the shell's when it says so (`section` and
* `onSection`), so the inspector on the other side can open the Theme or the
@@ -1,5 +1,7 @@
import type { BookDocument } from '@trek/shared';
import { normalizeBookDocument } from '@trek/shared';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
@@ -9,11 +11,11 @@ import type { StudioUploader } from '../studioUpload';
import { RAIL_SECTIONS, type RailSection } from './railSections';
/**
* FE-STUDIO-RAIL-001 to FE-STUDIO-RAIL-006: the rail down the side of Studio
* FE-STUDIO-RAIL-001 to FE-STUDIO-RAIL-007: the rail down the side of Studio
* and the panel it opens. Every section is a tab that opens its own panel
* under one head, the keyboard reaches every tab, a long name wraps inside
* the rail, and the shell can hold the open section so the inspector can open
* one from the other side.
* the rail and only between words, and the shell can hold the open section so
* the inspector can open one from the other side.
*/
const SOURCE: JourneySource = { entries: [], photos: [], photoEntries: {} };
@@ -54,6 +56,16 @@ function draw(over: { section?: RailSection; onSection?: (s: RailSection) => voi
);
}
/** Studio's stylesheet, which jsdom does not apply: its rules are the assertion. */
const STUDIO_CSS = readFileSync(resolve(process.cwd(), 'src/styles/studio.css'), 'utf8');
/** The rule written for exactly this selector, searched in `source` (one media query, say). */
function rule(selector: string, source = STUDIO_CSS): string {
const at = source.indexOf(`${selector} {`);
expect(at, `${selector} missing from studio.css`).toBeGreaterThan(-1);
return source.slice(at, source.indexOf('}', at) + 1);
}
/** The head of the panel that is open: the name every panel opens on. */
const panelTitle = () => must(document.querySelector('.st-side .st-panel-head .st-what-label')).textContent;
@@ -168,4 +180,42 @@ describe('the rail', () => {
fireEvent.click(screen.getByRole('tab', { name: 'journey.studio.stickers' }));
expect(screen.getByRole('textbox', { name: 'journey.studio.searchStickers' })).toBeTruthy();
});
it('FE-STUDIO-RAIL-007: wraps a name only between words, the rail growing to its longest word instead', () => {
// "Elements" in the label's 9.5px Poppins is 50.4px wide and a 52px tab left
// it 48px, so overflow-wrap: anywhere cut it into "Element" and "s" wherever
// no hyphenation dictionary was at hand. Catalan's "Maquetacions" is 77px and
// a larger text size widens every word, so no fixed width holds them all.
draw();
const selector = '.trek-dash .st-rail-btn .st-rail-label';
const names = [...document.querySelectorAll('.st-rail-label')];
expect(names).toHaveLength(RAIL_SECTIONS.length);
// Every name the rail renders is one the rule reaches; .trek-dash is the shell's root.
for (const name of names) expect(name.matches(selector.replace('.trek-dash ', ''))).toBe(true);
const label = rule(selector);
expect(label).toMatch(/overflow-wrap:\s*normal;/);
expect(label).toMatch(/word-break:\s*keep-all;/);
expect(label).toMatch(/hyphens:\s*manual;/);
expect(label).not.toMatch(/anywhere|break-all|break-word|hyphens:\s*auto/);
// The type stays on the caption step the user's text size scales.
expect(label).toMatch(/font-size:\s*calc\([\d.]+px \* var\(--fs-scale-caption, 1\)\)/);
// A tab takes the rail's width, and the rail's column is 64px or its
// min-content, which is the longest word, in each of the three layouts.
expect(rule('.trek-dash .st-rail-btn')).not.toMatch(/[\s;{]width:/);
const columns = [...STUDIO_CSS.matchAll(/\.st-body \{[^}]*?grid-template-columns:\s*([^;]+);/g)].map((m) => m[1]);
expect(columns).toEqual([
'minmax(64px, min-content) 236px minmax(0, 1fr) 300px',
'minmax(64px, min-content) 236px minmax(0, 1fr)',
'minmax(64px, min-content) minmax(0, 1fr)',
]);
// Below 1080 the panel floats over the workbench's own cell, so it starts
// where the rail ends, not at a fixed 64px from the edge.
const at = STUDIO_CSS.indexOf('@media (max-width: 1079px) {');
expect(at, 'the narrow layout missing from studio.css').toBeGreaterThan(-1);
const narrow = STUDIO_CSS.slice(at, STUDIO_CSS.indexOf('\n}', at));
expect(narrow).toContain('.trek-dash .st-side, .trek-dash .st-work-wrap { grid-area: 1 / 2; }');
expect(rule('.trek-dash .st-side', narrow)).not.toMatch(/position:\s*absolute|inset-inline-start/);
});
});
@@ -11,9 +11,9 @@ import { RAIL_IDS, RAIL_SECTIONS, type RailSection } from './railSections';
* A list of tabs to a screen reader and to the keyboard, as it is to the eye:
* Tab reaches the open section, the arrows move up and down the rail, Home and
* End jump to its ends, and Enter or Space opens a section (studioRoving.ts).
* A name longer than the rail is wide wraps onto a second line and breaks
* inside a word only when the word alone is wider than the rail; the title
* carries it whole either way.
* A name longer than the rail is wide wraps onto a second line, only ever
* between words: where one word alone is wider, the rail grows to fit it
* (studio.css) instead of cutting it. The title carries the name whole.
*/
export function StudioRail({
section,
+13 -13
View File
@@ -211,16 +211,17 @@
.trek-dash .st-sep { width: 1px; height: 20px; background: var(--line-2); margin: 0 2px; flex: none; }
/* ----------------- body: rail | workbench | inspector ----------------- */
/* The rail is 64px, or as wide as the longest word in a section's name. */
.trek-dash .st-body {
display: grid;
grid-template-columns: 64px 236px minmax(0, 1fr) 300px;
grid-template-columns: minmax(64px, min-content) 236px minmax(0, 1fr) 300px;
min-height: 0;
}
/* Under 1280 the inspector floats over the workbench instead of narrowing it —
below roughly 700px of canvas a spread stops being legible at any zoom. */
@media (max-width: 1279px) {
.trek-dash .st-body { grid-template-columns: 64px 236px minmax(0, 1fr); }
.trek-dash .st-body { grid-template-columns: minmax(64px, min-content) 236px minmax(0, 1fr); }
.trek-dash .st-inspector {
position: absolute;
top: 56px;
@@ -231,12 +232,11 @@
}
}
@media (max-width: 1079px) {
.trek-dash .st-body { grid-template-columns: 64px minmax(0, 1fr); }
.trek-dash .st-body { grid-template-columns: minmax(64px, min-content) minmax(0, 1fr); }
/* The panel floats over the workbench's cell, so it starts wherever the rail ends. */
.trek-dash .st-side, .trek-dash .st-work-wrap { grid-area: 1 / 2; }
.trek-dash .st-side {
position: absolute;
top: 56px;
inset-inline-start: 64px;
bottom: 0;
justify-self: start;
width: 236px;
box-shadow: var(--sh-lg);
}
@@ -457,7 +457,6 @@
}
.trek-dash .st-rail-btn {
flex: none;
width: 52px;
min-height: 52px;
padding: 6px 2px;
display: flex;
@@ -469,9 +468,9 @@
color: var(--ink-3);
transition: background .16s, color .16s;
}
/* Two lines at most, broken inside a word that is longer than the rail is wide
("Путешествие", "Tərtibatlar"); the button's title carries the whole name. */
.trek-dash .st-rail-btn span {
/* Wrapped between words, never inside one: the rail grows to the longest word
("Maquetacions", "Путешествие"), and keep-all holds "コンテンツ" whole too. */
.trek-dash .st-rail-btn .st-rail-label {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
@@ -481,8 +480,9 @@
font-weight: 600;
line-height: 1.15;
text-align: center;
overflow-wrap: anywhere;
hyphens: auto;
overflow-wrap: normal;
word-break: keep-all;
hyphens: manual;
}
.trek-dash .st-rail-btn:focus { outline: none; }
.trek-dash .st-rail-btn:focus-visible { box-shadow: 0 0 0 2px var(--accent-soft), 0 0 0 3px var(--accent); }