diff --git a/app/game-frontend/e2e/infoMobileLayout.spec.ts b/app/game-frontend/e2e/infoMobileLayout.spec.ts
index 76068d2c..34003356 100644
--- a/app/game-frontend/e2e/infoMobileLayout.spec.ts
+++ b/app/game-frontend/e2e/infoMobileLayout.spec.ts
@@ -233,6 +233,8 @@ const measure = async (page: Page, root: string, table: string, name: string) =>
rows: rows.map((row) => ({
rect: rect(row),
cells: Array.from(row.querySelectorAll('td'), (cell) => ({
+ field: cell.dataset.field,
+ gridArea: getComputedStyle(cell).gridArea,
rect: rect(cell),
text: cell.innerText.replace(/\s+/g, ''),
scrollWidth: cell.scrollWidth,
@@ -287,6 +289,17 @@ for (const [route, root, table] of [
expect(mobile.rows.map((row) => row.cells.map((cell) => cell.text))).toEqual(
desktop.rows.map((row) => row.cells.map((cell) => cell.text))
);
+ // Every general shares the same columns, including NPCs and empty reservations.
+ for (const row of mobile.rows) {
+ const turns = row.cells.find((cell) => cell.field === 'turns')!;
+ expect(turns.rect.width).toBeCloseTo(166, 0);
+ for (const [index, cell] of row.cells.entries()) {
+ const reference = mobile.rows[0]!.cells[index]!;
+ expect(cell.gridArea).toBe(reference.gridArea);
+ expect(cell.rect.x).toBeCloseTo(reference.rect.x, 1);
+ expect(cell.rect.width).toBeCloseTo(reference.rect.width, 1);
+ }
+ }
for (const row of mobile.rows)
for (const cell of row.cells) {
expect(cell.rect.width).toBeGreaterThan(0);
@@ -378,7 +391,14 @@ test('idle and empty information lists stay compact; doubled text wraps without
const desktop = await measure(page, root, table, `idle-${route.replace('/', '-')}-1000`);
await page.setViewportSize({ width: 500, height: 900 });
const mobile = await measure(page, root, table, `idle-${route.replace('/', '-')}-500`);
- expect(mobile.table.height / desktop.table.height).toBeLessThan(1.8);
+ if (route === 'nation/secret') {
+ // The uniform four-line layout intentionally replaces the former NPC-only two-line layout.
+ // Bound the ordinary row directly; desktop idle rows have no command height to share.
+ for (const row of mobile.rows.slice(1)) expect(row.rect.height).toBeLessThan(76);
+ expect(mobile.table.height).toBeLessThan(7600);
+ } else {
+ expect(mobile.table.height / desktop.table.height).toBeLessThan(1.8);
+ }
expect(mobile.document.width).toBe(500);
await page.unroute(gameTrpcRoute);
await install(page);
diff --git a/app/game-frontend/src/views/CurrentCityView.vue b/app/game-frontend/src/views/CurrentCityView.vue
index 794d7c02..28b7bcc6 100644
--- a/app/game-frontend/src/views/CurrentCityView.vue
+++ b/app/game-frontend/src/views/CurrentCityView.vue
@@ -514,7 +514,7 @@ const commandBrief = (command: ReservedCommand): string =>
}
.generals tbody tr {
display: grid;
- grid-template-columns: 64px 62px 68px 40px 40px minmax(0, 1fr);
+ grid-template-columns: 64px 74px 90px 52px 52px 166px;
grid-template-areas:
'icon name name name name turns'
'icon lead str intel intel turns'
@@ -539,6 +539,7 @@ const commandBrief = (command: ReservedCommand): string =>
content: attr(data-label);
margin-right: 3px;
font-size: 11px;
+ line-height: 1;
color: #bbb;
}
.generals [data-field='train'],
diff --git a/app/game-frontend/src/views/NationSecretView.vue b/app/game-frontend/src/views/NationSecretView.vue
index 3f192c8a..16d571c4 100644
--- a/app/game-frontend/src/views/NationSecretView.vue
+++ b/app/game-frontend/src/views/NationSecretView.vue
@@ -241,7 +241,6 @@ onMounted(load);
v-for="general in generals"
:key="general.id"
:data-general-id="general.id"
- :class="{ 'has-commands': general.npcState < 2 && general.reservedCommands.length > 0 }"
:data-npc-state="general.npcState"
>
@@ -525,8 +524,7 @@ th,
}
}
-/* 500px에서는 명령을 숨기지 않고 그 옆 높이를 통계에 사용한다.
- * 예약이 없는 장수는 전체 폭 2행으로 배치해 빈 명령 칸 때문에 길어지지 않는다. */
+/* 장수 종류와 예약 유무에 관계없이 정보 2/3, 명령 1/3의 같은 배치를 사용한다. */
@media (max-width: 939.98px) {
.secret-page {
width: 500px;
@@ -573,20 +571,16 @@ th,
}
.list tbody tr {
display: grid;
- grid-template-columns: repeat(100, minmax(0, 1fr));
+ grid-template-columns: repeat(12, minmax(0, 1fr)) 166px;
+ grid-template-areas:
+ 'name name name name name name stats stats stats stats stats stats turns'
+ 'troop troop troop troop troop troop city city city city defence defence turns'
+ 'gold gold gold gold gold rice rice rice rice time time time turns'
+ 'type type type crew crew crew train train atmos atmos kill kill turns';
height: auto;
border: 1px solid gray;
border-top: 0;
}
- .list tbody tr.has-commands {
- grid-template-columns: 64px 70px 70px 70px minmax(0, 1fr);
- grid-template-areas:
- 'name name stats stats turns'
- 'troop troop city city turns'
- 'gold gold rice rice turns'
- 'type crew train atmos turns'
- 'defence kill time time turns';
- }
.list tbody td {
min-width: 0;
padding: 0 2px;
@@ -600,6 +594,7 @@ th,
content: attr(data-label);
margin-right: 3px;
font-size: 11px;
+ line-height: 1;
color: #bbb;
}
.list tbody td[data-field='name'] br {
@@ -657,70 +652,5 @@ th,
.list [data-field='time'] {
grid-area: time;
}
- /* 예약이 없으면 서로 다른 열폭을 가진 2행을 100등분 grid 위에 놓는다.
- * 첫 행: 이름/능력/부대/도시/NPC, 둘째 행: 자원/병력/훈사/턴. */
- .list tbody tr:not(.has-commands) td {
- grid-area: auto;
- padding-inline: 1px;
- }
- .list tbody tr:not(.has-commands) td::before {
- margin-right: 2px;
- }
- .list tbody tr:not(.has-commands) [data-field='name'] {
- grid-row: 1;
- grid-column: 1 / span 27;
- }
- .list tbody tr:not(.has-commands) [data-field='stats'] {
- grid-row: 1;
- grid-column: 28 / span 28;
- }
- .list tbody tr:not(.has-commands) [data-field='troop'] {
- grid-row: 1;
- grid-column: 56 / span 18;
- }
- .list tbody tr:not(.has-commands) [data-field='city'] {
- grid-row: 1;
- grid-column: 74 / span 12;
- }
- .list tbody tr:not(.has-commands) [data-field='turns'] {
- grid-row: 1;
- grid-column: 86 / span 15;
- }
- .list tbody tr:not(.has-commands) [data-field='gold'] {
- grid-row: 2;
- grid-column: 1 / span 19;
- }
- .list tbody tr:not(.has-commands) [data-field='rice'] {
- grid-row: 2;
- grid-column: 20 / span 16;
- }
- .list tbody tr:not(.has-commands) [data-field='defence'] {
- grid-row: 2;
- grid-column: 36 / span 7;
- }
- .list tbody tr:not(.has-commands) [data-field='type'] {
- grid-row: 2;
- grid-column: 43 / span 11;
- }
- .list tbody tr:not(.has-commands) [data-field='crew'] {
- grid-row: 2;
- grid-column: 54 / span 14;
- }
- .list tbody tr:not(.has-commands) [data-field='train'] {
- grid-row: 2;
- grid-column: 68 / span 8;
- }
- .list tbody tr:not(.has-commands) [data-field='atmos'] {
- grid-row: 2;
- grid-column: 76 / span 8;
- }
- .list tbody tr:not(.has-commands) [data-field='kill'] {
- grid-row: 2;
- grid-column: 84 / span 6;
- }
- .list tbody tr:not(.has-commands) [data-field='time'] {
- grid-row: 2;
- grid-column: 90 / span 11;
- }
}
diff --git a/docs/frontend-legacy-parity.md b/docs/frontend-legacy-parity.md
index 465e9e1a..37364577 100644
--- a/docs/frontend-legacy-parity.md
+++ b/docs/frontend-legacy-parity.md
@@ -125,12 +125,17 @@ the selected radio option so the user can retry.
`app/game-frontend/e2e/infoMobileLayout.spec.ts` verifies the intentional Core
500px layouts for `/nation/secret` and `/current-city`. Both keep every visible
cell, command and image from the desktop dataset. Statistics share vertical space
-with reserved commands; current-city portraits remain 64px. Empty reservation
-cells in the secret list use a shorter full-width two-row layout.
+with reserved commands; current-city portraits remain 64px. Both lists reserve
+166px of the 500px table for commands and 332px for information (plus borders).
+Secret rows use the same four-line field positions for players, NPCs and empty
+reservations. Long content wraps without truncation.
The fixture measures 100 generals with five-command and NPC rows at CSS viewport
widths 1000, 500, 501 and 800. The mixed-list height must stay below 1.5 times the
-same desktop list, and page height below 1.55 times; idle lists stay below 1.8 times.
+same desktop list, and page height below 1.55 times. Idle current-city lists stay
+below 1.8 times; ordinary idle secret rows stay below 76px and the 100-row fixture
+below 7600px. The secret idle limit replaces the old NPC-specific two-row limit
+to preserve consistent field positions, as requested in the follow-up review.
These limits apply to the deterministic fixture, not arbitrary-length user text.
It also verifies exact cell text preservation, no horizontal overflow, sorting,
injury tooltip interaction, city selection, empty lists, doubled text, and actual
|