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); + } }