From 7658443cd52c9de0a354e40dd0043a37fd68c051 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Tue, 18 Aug 2026 16:34:35 +0300 Subject: [PATCH] Enhance UI components for map editing and school creation. Update styles for improved layout and responsiveness, including adjustments to dialog positioning and flex properties. Introduce new localization strings for better user guidance in both English and Russian. Refactor code structure for clarity and maintainability, ensuring a more intuitive user experience. --- AGENTS.md | 3 + src/HSchool.Client/src/i18n/strings.ts | 12 +- src/HSchool.Client/src/style.css | 236 ++++++++++++++---- .../src/ui/createSchoolDialog.ts | 11 +- src/HSchool.Client/src/ui/gameScreen.ts | 39 ++- src/HSchool.Client/src/ui/mapEditor.ts | 136 ++++++---- src/HSchool.Content/MapView.cs | 20 +- .../mods/core/localizations/ru.jsonc | 2 +- tests/HSchool.Content.Tests/MapViewTests.cs | 38 +++ 9 files changed, 378 insertions(+), 119 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d28a425..3ce7914 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,6 +130,9 @@ say so explicitly in the change description. - **``'s `close` event is not delivered by every engine** (Chromium 148 fires only `toggle`). `ui/modal.ts` resolves its promise explicitly on every exit for that reason — never go back to awaiting the event, or a dialog will silently freeze the screen that awaited it. +- **A modal `` is absolutely positioned with `inset: 0`,** so `height: auto` stretches it + to its `max-height` instead of shrink-wrapping. `.dialog--screen` uses `height: fit-content` for + that reason — the map editor sized itself to 800px around a two-room school before it did. - **Game dates are UTC on the wire and UTC when formatted.** A `DateTime` bound from configuration arrives as `Kind=Unspecified`, serializes without a `Z`, and the browser then reads it in its own time zone — `SimulationOptions.DefaultStartDate` forces the kind for exactly that reason. diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index 13d80ce..1a1fb62 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -51,7 +51,11 @@ const ru = { mapEditedHint: 'Карта изменена.', resetMap: 'Сбросить к умолчанию', done: 'Готово', - editorSlots: 'Слоты', + editorStructure: 'Структура', + editorSlots: 'Обстановка', + editorPickRoom: 'Выберите комнату в дереве слева.', + editorRooms: 'Комнаты', + editorNoLinks: 'Проходов нет.', editorLinks: 'Проходы', addLink: 'Связать', removeLink: 'Убрать', @@ -130,7 +134,11 @@ const en: Messages = { mapEditedHint: 'The map has been edited.', resetMap: 'Reset to default', done: 'Done', - editorSlots: 'Slots', + editorStructure: 'Structure', + editorSlots: 'Furnishing', + editorPickRoom: 'Pick a room in the tree on the left.', + editorRooms: 'Rooms', + editorNoLinks: 'No passages yet.', editorLinks: 'Passages', addLink: 'Link', removeLink: 'Remove', diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css index a7e4d5f..cec7cae 100644 --- a/src/HSchool.Client/src/style.css +++ b/src/HSchool.Client/src/style.css @@ -3,6 +3,7 @@ --surface: #10141c; --surface-raised: #171d28; + --surface-sunken: #0c1017; --border: #2a3242; --text: #d7e0ef; --text-muted: #8b98ad; @@ -19,14 +20,39 @@ body { margin: 0; - min-height: 100vh; + min-height: 100dvh; background: var(--surface); } +/* + * The shell fills the window. A management screen that stops halfway down a very wide page reads + * as a half-loaded document rather than a game, so every screen is a full-height flex column and + * decides its own comfortable width. + */ #app { - max-width: 1080px; + display: flex; + min-height: 100dvh; + padding: 24px 24px 52px; +} + +.screen { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + min-height: 0; + width: 100%; margin: 0 auto; - padding: 32px 20px 72px; +} + +/* A short list of saves sits in the middle of the window instead of clinging to the top edge. */ +.screen.menu { + max-width: 900px; + justify-content: center; +} + +.screen.game { + max-width: 1500px; } #status { @@ -73,7 +99,7 @@ body { display: flex; align-items: center; gap: 16px; - margin-bottom: 20px; + margin-bottom: 16px; } .screen__title { @@ -100,15 +126,16 @@ body { .card-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + align-content: start; gap: 16px; } .card { display: flex; flex-direction: column; - gap: 8px; - padding: 16px; + gap: 6px; + padding: 18px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface-raised); @@ -151,75 +178,109 @@ body { } .card__actions { - margin-top: 8px; + display: flex; + justify-content: flex-end; + margin-top: 10px; } /* Clock */ -.clock { - padding: 28px; +/* + * Inside a school the calendar is a toolbar, not a hero banner: the panels below are the screen, + * and a 56px centred clock pushed them down for no information gained. + */ +.clockbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 16px; + padding: 12px 18px; border: 1px solid var(--border); - border-radius: 16px; + border-radius: 14px; background: var(--surface-raised); - text-align: center; +} + +.clockbar__now { + display: flex; + align-items: center; + gap: 16px; + min-width: 0; } .clock__time { margin: 0; - font-size: 56px; + font-size: 34px; font-weight: 600; font-variant-numeric: tabular-nums; letter-spacing: 0.02em; + line-height: 1.1; +} + +.clockbar__labels { + display: flex; + flex-direction: column; + min-width: 0; } .clock__date { - margin: 4px 0 0; - font-size: 18px; + margin: 0; + font-size: 15px; } .clock__weekday { - margin: 2px 0 0; + margin: 0; color: var(--text-muted); + font-size: 13px; text-transform: capitalize; } .clock__controls { display: flex; - justify-content: center; + align-items: center; flex-wrap: wrap; gap: 8px; - margin-top: 20px; -} - -.screen.game .clock { - padding: 20px; -} - -.screen.game .clock__time { - font-size: 40px; + margin-left: auto; } /* Manager */ .manager { display: grid; - grid-template-columns: minmax(180px, 1fr) minmax(200px, 1fr) minmax(240px, 1.35fr); + grid-template-columns: minmax(210px, 0.85fr) minmax(240px, 1fr) minmax(280px, 1.25fr); gap: 16px; - margin-top: 20px; + flex: 1; + min-height: 0; + margin-top: 16px; } .panel { + display: flex; + flex-direction: column; min-width: 0; - padding: 16px; + min-height: 0; border: 1px solid var(--border); border-radius: 12px; background: var(--surface-raised); } .panel__title { - margin: 0 0 12px; - font-size: 15px; + flex: 0 0 auto; + margin: 0; + padding: 11px 16px; + border-bottom: 1px solid var(--border); + color: var(--text-muted); + font-size: 12px; font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +/* The body scrolls, the title stays: a long tree must not push the panel out of the window. */ +.panel__body { + flex: 1; + min-height: 0; + overflow: auto; + padding: 12px 12px 16px; } .panel__name { @@ -229,7 +290,11 @@ body { } .panel__section { - margin-top: 12px; + margin-top: 14px; +} + +.panel__section:first-child { + margin-top: 0; } .panel__section-title { @@ -286,9 +351,20 @@ body { color: var(--accent); } -@media (max-width: 800px) { +/* Below three columns the panels stop competing for height and the page scrolls instead. */ +@media (max-width: 900px) { + #app { + min-height: 0; + } + .manager { - grid-template-columns: 1fr; + display: flex; + flex-direction: column; + flex: 0 0 auto; + } + + .panel__body { + overflow: visible; } } @@ -302,6 +378,7 @@ body { color: var(--text); font: inherit; font-size: 14px; + white-space: nowrap; cursor: pointer; transition: border-color 120ms ease, background 120ms ease; } @@ -342,11 +419,11 @@ body { } .input { - flex: 1; + min-width: 0; padding: 8px 12px; border: 1px solid var(--border); border-radius: 8px; - background: var(--surface); + background: var(--surface-sunken); color: var(--text); font: inherit; font-size: 14px; @@ -361,7 +438,7 @@ body { .dialog { min-width: 340px; - max-width: 440px; + max-width: 460px; padding: 24px; border: 1px solid var(--border); border-radius: 14px; @@ -369,13 +446,20 @@ body { color: var(--text); } +/* + * The editor grows with its map instead of always claiming 800px: a two-room school framed by a + * half-empty window was the loudest thing on screen. + */ .dialog--screen[open] { display: flex; flex-direction: column; min-width: unset; max-width: none; - width: min(1080px, calc(100vw - 24px)); - height: min(800px, calc(100vh - 24px)); + width: min(1080px, calc(100vw - 32px)); + /* A modal dialog is absolutely positioned with `inset: 0`, so `height: auto` stretches it to the + max instead of shrink-wrapping. `fit-content` is what actually lets it size to its map. */ + height: fit-content; + max-height: calc(100dvh - 48px); padding: 16px 20px 20px; } @@ -389,21 +473,12 @@ body { } .dialog--screen .map-editor { - flex: 1; - min-height: 0; - grid-template-columns: minmax(220px, 0.9fr) minmax(280px, 1.3fr); + flex: 1 1 auto; + min-height: min(400px, 55dvh); + grid-template-columns: minmax(220px, 0.9fr) minmax(300px, 1.3fr); gap: 16px; } -.dialog--screen .map-editor__pane, -.dialog--screen .map-editor__details { - min-height: 0; - overflow: auto; - border: 1px solid var(--border); - border-radius: 10px; - padding: 10px; -} - .dialog--screen .dialog__actions { flex: 0 0 auto; margin-top: 12px; @@ -453,6 +528,20 @@ body { color: var(--text-muted); } +/* + * Without this the rows fell back to inline-block flow, `.input { flex: 1 }` never applied and + * every field in the create form ended up a different width. + */ +.field__row { + display: flex; + align-items: center; + gap: 8px; +} + +.field__row > .input { + flex: 1; +} + .field .hint { margin: 0; font-size: 13px; @@ -460,8 +549,8 @@ body { .mod-list { display: flex; - flex-direction: column; - gap: 6px; + flex-wrap: wrap; + gap: 8px 16px; } .mod-list__item { @@ -471,16 +560,53 @@ body { font-size: 14px; } +/* Map editor */ + .map-editor { display: grid; - grid-template-columns: minmax(180px, 1fr) minmax(240px, 1.4fr); + grid-template-columns: minmax(200px, 0.9fr) minmax(280px, 1.3fr); gap: 12px; } +.map-editor__pane, .map-editor__details { display: flex; flex-direction: column; - gap: 10px; + min-width: 0; + min-height: 0; + gap: 12px; + overflow: auto; +} + +.editor-section { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface-sunken); +} + +.editor-section--grow { + flex: 1; + min-height: 0; + overflow: auto; +} + +.editor-section__title { + margin: 0; + color: var(--text-muted); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.editor-section__hint { + margin: 0; + color: var(--text-muted); + font-size: 13px; } .map-editor__links { diff --git a/src/HSchool.Client/src/ui/createSchoolDialog.ts b/src/HSchool.Client/src/ui/createSchoolDialog.ts index 0eb6464..d44017c 100644 --- a/src/HSchool.Client/src/ui/createSchoolDialog.ts +++ b/src/HSchool.Client/src/ui/createSchoolDialog.ts @@ -41,7 +41,12 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise { + treeTitle.textContent = t('editorStructure'); paintTree(); paintDetails(); }; + /** Every group of controls gets a frame and a heading; loose selects read as debug output. */ + const section = (title: string, ...children: (Node | null)[]): HTMLElement => + el( + 'section', + { class: 'editor-section' }, + el('h3', { class: 'editor-section__title', text: title }), + ...children, + ); + const paintTree = (): void => { clear(tree); appendNode(tree, map.territory.id, labelOf(catalog, map, map.territory.id), 0); @@ -48,10 +63,7 @@ export function mapEditor(options: MapEditorOptions): { for (const building of map.buildings) { appendNode(tree, building.id, defLabel(catalog.buildings, building.def) ?? building.id, 1); for (const floor of map.floors.filter((candidate) => candidate.building === building.id)) { - const floorName = floor.label !== undefined && floor.label.length > 0 - ? floor.label - : (defLabel(catalog.floors, floor.def) ?? floor.id); - appendNode(tree, floor.id, floorName, 2); + appendNode(tree, floor.id, floorName(catalog, floor), 2); for (const room of map.rooms.filter((candidate) => candidate.floor === floor.id)) { appendNode(tree, room.id, defLabel(catalog.rooms, room.def) ?? room.id, 3); } @@ -77,42 +89,58 @@ export function mapEditor(options: MapEditorOptions): { const paintDetails = (): void => { clear(details); + details.append(paintSelected(), paintLinks(), paintRooms()); + }; + /** Furnishing of the selected room, or the reason there is nothing to furnish. */ + const paintSelected = (): HTMLElement => { const room = map.rooms.find((candidate) => candidate.id === selectedId); const roomDef = room === undefined ? undefined : catalog.rooms.find((def) => def.defName === room.def); + const title = `${t('editorSlots')} — ${labelOf(catalog, map, selectedId ?? map.territory.id)}`; - if (room !== undefined && roomDef !== undefined) { - details.append(el('p', { class: 'field__label', text: t('editorSlots') })); - for (const slot of roomDef.slots) { - const select = el('select', { class: 'input' }); - const empty = el('option', { text: t('slotEmpty') }); - empty.value = ''; - select.append(empty); - for (const thing of catalog.things) { - const option = el('option', { text: thing.label }); - option.value = thing.defName; - select.append(option); - } + if (room === undefined || roomDef === undefined) { + return section(title, el('p', { class: 'editor-section__hint', text: t('editorPickRoom') })); + } - const fill = (room.slots ?? []).find((candidate) => candidate.key === slot.key); - select.value = fill?.thing ?? ''; - select.addEventListener('change', () => { - const slots = [...(room.slots ?? [])].filter((candidate) => candidate.key !== slot.key); - if (select.value !== '') { - slots.push({ key: slot.key, thing: select.value }); - } - - room.slots = slots; - emit(); - }); - - details.append( - el('label', { class: 'field' }, el('span', { class: 'field__label', text: slot.key }), select), - ); + const rows: HTMLElement[] = []; + for (const slot of roomDef.slots) { + const select = el('select', { class: 'input' }); + const empty = el('option', { text: t('slotEmpty') }); + empty.value = ''; + select.append(empty); + for (const thing of catalog.things) { + const option = el('option', { text: thing.label }); + option.value = thing.defName; + select.append(option); } - const remove = el('button', { - class: 'button', + const fill = (room.slots ?? []).find((candidate) => candidate.key === slot.key); + select.value = fill?.thing ?? ''; + select.addEventListener('change', () => { + const slots = [...(room.slots ?? [])].filter((candidate) => candidate.key !== slot.key); + if (select.value !== '') { + slots.push({ key: slot.key, thing: select.value }); + } + + room.slots = slots; + emit(); + }); + + rows.push( + el( + 'label', + { class: 'field__row' }, + el('span', { class: 'field__label', text: slot.key }), + select, + ), + ); + } + + return section( + title, + ...rows, + el('div', { class: 'dialog__actions' }, el('button', { + class: 'button button--danger button--small', type: 'button', text: t('removeRoom'), onClick: () => { @@ -121,11 +149,11 @@ export function mapEditor(options: MapEditorOptions): { selectedId = map.territory.id; emit(); }, - }); - details.append(remove); - } + })), + ); + }; - details.append(el('p', { class: 'field__label', text: t('editorLinks') })); + const paintLinks = (): HTMLElement => { const linksList = el('ul', { class: 'map-editor__links' }); for (const [index, link] of map.links.entries()) { const row = el('li', { class: 'map-editor__link' }); @@ -144,12 +172,13 @@ export function mapEditor(options: MapEditorOptions): { linksList.append(row); } - details.append(linksList); - const walkable = walkableIds(map); const from = selectOf(walkable, catalog, map); const to = selectOf(walkable, catalog, map); - details.append( + + return section( + t('editorLinks'), + map.links.length > 0 ? linksList : el('p', { class: 'editor-section__hint', text: t('editorNoLinks') }), el( 'div', { class: 'field__row' }, @@ -172,7 +201,9 @@ export function mapEditor(options: MapEditorOptions): { }), ), ); + }; + const paintRooms = (): HTMLElement => { const roomSelect = el('select', { class: 'input' }); for (const def of catalog.rooms) { const option = el('option', { text: def.label }); @@ -180,7 +211,8 @@ export function mapEditor(options: MapEditorOptions): { roomSelect.append(option); } - details.append( + return section( + t('editorRooms'), el( 'div', { class: 'field__row' }, @@ -277,6 +309,22 @@ function slug(defName: string): string { .toLowerCase(); } +/** + * A floor's `label` is its designation, not a replacement name — the vanilla map says "1". Shown + * on its own it is a stray digit in the tree, so it follows the def label: "Этаж 1", "Floor 1". + * Mirrors `MapView.FloorName` on the server. + */ +function floorName(catalog: CatalogResponse, floor: MapLayout['floors'][number]): string { + const def = defLabel(catalog.floors, floor.def); + const label = floor.label ?? ''; + + if (def === undefined) { + return label.length > 0 ? label : floor.id; + } + + return label.length > 0 ? `${def} ${label}` : def; +} + function defLabel(defs: readonly { defName: string; label: string }[], defName: string): string | undefined { return defs.find((def) => def.defName === defName)?.label; } @@ -293,9 +341,7 @@ function labelOf(catalog: CatalogResponse, map: MapLayout, id: string): string { const floor = map.floors.find((node) => node.id === id); if (floor !== undefined) { - return floor.label !== undefined && floor.label.length > 0 - ? floor.label - : (defLabel(catalog.floors, floor.def) ?? id); + return floorName(catalog, floor); } const room = map.rooms.find((node) => node.id === id); diff --git a/src/HSchool.Content/MapView.cs b/src/HSchool.Content/MapView.cs index a90de37..6700e93 100644 --- a/src/HSchool.Content/MapView.cs +++ b/src/HSchool.Content/MapView.cs @@ -63,13 +63,11 @@ public static class MapView foreach (var floor in map.Floors) { - var defLabel = LabelOf(catalog, locale, DefKind.Floor, floor.Def); - var name = string.IsNullOrWhiteSpace(floor.Label) ? defLabel : floor.Label; nodes.Add(Node( MapNodeKind.Floor, floor.Id, floor.Building, - name, + FloorName(catalog, locale, floor), [], PositionsOf(catalog, locale, DefKind.Floor, floor.Def))); } @@ -111,6 +109,22 @@ public static class MapView Positions = positions, }; + /// + /// A floor's is its designation, not a replacement name — the + /// vanilla map says "1". On its own that is a stray digit in the client's tree, so it follows + /// the localized def label: "Этаж 1", "Floor 1". Mirrored by floorName in the editor. + /// + private static string FloorName(DefCatalog catalog, string locale, FloorNode floor) + { + var defLabel = LabelOf(catalog, locale, DefKind.Floor, floor.Def); + if (string.IsNullOrWhiteSpace(floor.Label)) + { + return defLabel; + } + + return defLabel == floor.Def ? floor.Label : $"{defLabel} {floor.Label}"; + } + private static string LabelOf(DefCatalog catalog, string locale, DefKind kind, string defName) => catalog.TryGet(kind, defName, out var def) ? catalog.Label(locale, def) : defName; diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc index 706036a..1d009f4 100644 --- a/src/HSchool.Server/mods/core/localizations/ru.jsonc +++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc @@ -9,7 +9,7 @@ "WalkSchool": "Обход школы", "SchoolYard": "Двор", "MainBuilding": "Главный корпус", - "StandardFloor": "Этажи", + "StandardFloor": "Этаж", "Corridor": "Коридор", "PrincipalsOffice": "Кабинет директора", } diff --git a/tests/HSchool.Content.Tests/MapViewTests.cs b/tests/HSchool.Content.Tests/MapViewTests.cs index 9acd00b..e8f86ef 100644 --- a/tests/HSchool.Content.Tests/MapViewTests.cs +++ b/tests/HSchool.Content.Tests/MapViewTests.cs @@ -33,4 +33,42 @@ public class MapViewTests Assert.Empty(corridor.Items); Assert.Empty(corridor.Positions); } + + [Fact] + public void FloorLabel_FollowsTheDefLabelInsteadOfReplacingIt() + { + var catalog = new CatalogLoader().Load( + [CatalogLoader.CorePackId], + [ + new ContentDocument( + CatalogLoader.CorePackId, + "defs/floors/standard.jsonc", + """{ "defName": "StandardFloor" }"""), + new ContentDocument( + CatalogLoader.CorePackId, + "defs/territories/yard.jsonc", + """{ "defName": "Yard" }"""), + new ContentDocument( + CatalogLoader.CorePackId, + "localizations/ru.jsonc", + """{ "StandardFloor": "Этаж", "Yard": "Двор" }"""), + ]); + + var map = new MapLayout + { + Territory = new TerritoryNode { Id = "yard", Def = "Yard" }, + Buildings = [new BuildingNode { Id = "main", Def = "Main" }], + Floors = + [ + new FloorNode { Id = "floor-1", Def = "StandardFloor", Building = "main", Label = "1" }, + new FloorNode { Id = "floor-2", Def = "StandardFloor", Building = "main" }, + ], + }; + + var nodes = MapView.Build(catalog, map, "ru"); + + // A bare "1" in the tree is the label on its own; the def label carries the noun. + Assert.Equal("Этаж 1", nodes.Single(node => node.Id == "floor-1").Name); + Assert.Equal("Этаж", nodes.Single(node => node.Id == "floor-2").Name); + } }