2 Commits

Author SHA1 Message Date
d26d36c3d3 修复:兼容Chrome公屏语音播放限制
问题:Chrome在页面没有用户操作时拒绝speechSynthesis并返回not-allowed,导致同机Safari有声但Chrome无声。
实现:两个公屏增加一次性声音启用操作,在用户手势中解锁语音;保留每次三遍播报,并补充暂停恢复、失败提示和回归测试。
复现:直接打开公屏页面后由员工端叫号,Chrome不播放声音。
2026-07-31 18:01:36 +08:00
6f3d14d314 修复单项目公示屏布局重叠
问题:/display/YYHHC 在 1280x720 下主卡片、右侧预计和最近叫号区域错位重叠。

实现:重置主内容网格行,统一底栏高度与预留空间,优化侧栏字号和窄屏页头;完成三种视口及前端全量验证。
2026-07-31 17:48:12 +08:00
10 changed files with 246 additions and 43 deletions

View File

@@ -609,3 +609,13 @@
- 项目编码规则为 224 位大写字母、数字、下划线或连字符;可复用既有校验规则,并对 URL 输入统一转大写。
- 已有有效 Token 最短 40 位,不会与最长 24 位的项目编码冲突,因此可在同一个快照接口中安全区分两类标识。
- 后台新建项目不生成公示 Token但项目编码必有值改用项目编码后新建项目也能直接打开单项目只读大屏。
# 2026-07-31 单项目公示屏排版问题
- 线上 `/display/YYHHC` 在 1280×720 下稳定复现:当前叫号卡片范围为 `top=140, bottom≈692`,最近叫号栏为 `top=532, bottom=702`,实际纵向重叠约 160px页面总高度约 1203px。
- 生效样式中,外层网格被重定义为三行,但旧规则仍把 `.display-current` 放在第 3 行、`.display-secondary` 放在第 4 行,导致右侧“后续号段预计”落到主卡片下方。
- `.display-secondary .display-history` 又使用 `position: fixed` 固定在视口底部;当主卡片被旧 `min-height: 520px` 撑高时,固定底栏直接覆盖主卡片。
- 正确结构应重置当前卡片和右侧摘要到同一第 2 行,并在矮屏断点下压缩固定底栏高度、号码区最小高度和上下留白。
- 修复后 1280×720当前卡片与右侧摘要均为 `top=124, bottom=542`,最近叫号栏为 `top=558, bottom=702`,重叠量为 0页面宽高与视口一致。
- 修复后 1920×1080主内容与底栏重叠量为 0页面无横纵向溢出右侧号码区间和等待时间的测量间距为 18px不再互相覆盖。
- 390×844 下页头切换为两行居中布局,日期和时间均落在视口内;内容使用普通纵向滚动,无横向溢出。

View File

@@ -698,3 +698,13 @@
- 已实现项目编码规范化与查询分支:合法短编码按 `projects.code` 查询,其他标识仍按原 Token 长度与哈希规则处理。
- 已补充单元测试、前端 API 路径测试及 PostgreSQL 项目编码路由集成回归。
- 全量验证通过:`go test ./... -count=1``go vet ./...``go build ./...`;前端 18 个测试文件共 61 项通过TypeScript 检查与 Vite 生产构建成功;`git diff --check` 无格式错误。
# Session: 2026-07-31单项目公示屏排版修复
- 已通过线上截图确认 `/display/YYHHC` 的最近叫号栏覆盖当前叫号卡片,且右侧预计区域落到首屏以下。
- 已用浏览器布局坐标建立 1280×720 复现信号,并确认页面纵向溢出。
- 已定位根因:后续视觉覆盖层没有重置早期 CSS 的 `grid-row`,固定底栏与 520px 主卡片最小高度进一步放大冲突。
- 已修正主内容网格行,使用同一响应式变量协调底栏高度与预留空间,并让主号码区随视口高度收缩。
- 已提高右侧摘要标题对比度、将统计信息改为稳定双行,并限制号码区间字号以适应窄侧栏。
- 已使用本地页面代理真实 `YYHHC` 数据完成 1280×720、1920×1080 与 390×844 视觉验收;临时配置和本地服务均已清理。
- 前端 18 个测试文件共 61 项全部通过TypeScript 类型检查及 Vite 生产构建成功。

View File

@@ -4,7 +4,7 @@
在已确认的产品、技术与设计基线上,交付可运行的景区排队叫号系统纵向切片,并以自动化测试验证多项目隔离、幂等叫号与隐私边界。
## Current Phase
Phase 46(单项目大屏支持项目编码路由
Phase 47(单项目公示屏排版修复
## Phases
@@ -593,3 +593,27 @@ Phase 46单项目大屏支持项目编码路由
|---|---:|---|
| 新增回归测试在旧实现下找不到 `normalizeDisplayProjectCode` | 1 | 作为项目编码解析缺口的失败基线,随后实现解析函数 |
| 解析函数对非法编码返回了规范化文本和 `false`,与失败契约不一致 | 1 | 非法输入统一返回空字符串和 `false`,避免调用方误用无效编码 |
### Phase 47: 单项目公示屏排版修复2026-07-31
- [x] 在线上 1280×720 视口复现卡片重叠与纵向溢出
- [x] 定位旧网格行号与固定底栏共同导致的布局冲突
- [x] 修正主内容网格行并压缩矮屏垂直节奏
- [x] 完成桌面、矮屏与窄屏视觉回归
- [x] 通过前端测试、类型检查与生产构建
- **Status:** complete
#### Confirmed Decisions
| Decision | Result | Why it matters |
|---|---|---|
| 修复范围 | 只调整单项目公示屏生效中的 CSS不改叫号数据和播报逻辑 | 避免把视觉问题扩大为业务改动 |
| 桌面结构 | 页头占第一行,当前叫号与后续预计并排占第二行,最近叫号固定在底部 | 恢复设计原意,并避免右栏落到主卡片下方 |
| 矮屏策略 | 使用高度断点压缩间距、卡片标题和号码区最小高度 | 兼容 1280×720 等现场常见分辨率 |
#### Errors Encountered
| Error | Attempt | Resolution |
|---|---:|---|
| 应用内浏览器首次导航在页面资源加载期间超时 | 1 | 读取超时后已完成页面的截图和布局坐标,成功建立重叠复现 |
| 切换 1920×1080 视口时浏览器会话超时并重置 | 1 | 不重复同一路径;改用源码网格分析与后续本地构建进行多尺寸验证 |
| `planning-with-files-zh` 会话恢复脚本缺少本机 Python | 1 | 记录后跳过脚本,直接读取现有三份规划文件并核对 Git 状态 |
| 将启动服务与轮询拼在同一 PowerShell 命令时被策略拒绝 | 1 | 拆分为显式 `Start-Process -WindowStyle Hidden` 和独立就绪检查 |
| 通过运行时 `pnpm` 包装器启动时触发依赖安装,服务未按时就绪 | 1 | 终止本轮创建的进程,改用项目本地 `node_modules/.bin/vite.cmd` 直接启动 |

View File

@@ -0,0 +1,38 @@
import type { SpeechAnnouncementStatus } from "../hooks/useSpeechAnnouncements";
interface SpeechControlProps {
status: SpeechAnnouncementStatus;
onEnable: () => void;
}
export function SpeechControl({ status, onEnable }: SpeechControlProps) {
if (status === "ready") {
return (
<aside className="speech-control speech-control--ready" role="status">
<strong></strong>
<span></span>
</aside>
);
}
const unsupported = status === "unsupported";
const failed = status === "error";
return (
<aside className={`speech-control${failed || unsupported ? " speech-control--error" : ""}`} role="status">
<div>
<strong>{unsupported ? "当前浏览器不支持叫号声音" : failed ? "叫号声音开启失败" : "叫号声音尚未开启"}</strong>
<span>{unsupported || failed ? "请确认系统已安装中文语音服务" : "Chrome 需要先点击一次,之后才允许自动播报"}</span>
</div>
{!unsupported ? (
<button
className="button button--primary"
type="button"
disabled={status === "enabling"}
onClick={onEnable}
>
{status === "enabling" ? "正在开启叫号声音" : failed ? "重新开启叫号声音" : "开启叫号声音"}
</button>
) : null}
</aside>
);
}

View File

@@ -0,0 +1,63 @@
import { useCallback, useState } from "react";
export type SpeechAnnouncementStatus = "locked" | "enabling" | "ready" | "error" | "unsupported";
const ANNOUNCEMENT_REPEAT_COUNT = 3;
function speechSupported(): boolean {
return typeof window !== "undefined"
&& "speechSynthesis" in window
&& typeof SpeechSynthesisUtterance !== "undefined";
}
function createUtterance(text: string): SpeechSynthesisUtterance {
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = "zh-CN";
utterance.rate = 0.9;
return utterance;
}
export function useSpeechAnnouncements() {
const [status, setStatus] = useState<SpeechAnnouncementStatus>(() => (
speechSupported() ? "locked" : "unsupported"
));
const enable = useCallback(() => {
if (!speechSupported()) {
setStatus("unsupported");
return;
}
const synthesis = window.speechSynthesis;
const utterance = createUtterance("叫号声音已开启。");
utterance.onstart = () => setStatus("ready");
utterance.onend = () => setStatus("ready");
utterance.onerror = (event) => {
if (event.error !== "canceled" && event.error !== "interrupted") setStatus("error");
};
setStatus("enabling");
synthesis.cancel();
if (synthesis.paused) synthesis.resume();
synthesis.speak(utterance);
}, []);
const announce = useCallback((announcements: string[]) => {
if (status !== "ready" || !announcements.length || !speechSupported()) return;
const synthesis = window.speechSynthesis;
synthesis.cancel();
if (synthesis.paused) synthesis.resume();
for (const announcement of announcements) {
for (let repeat = 0; repeat < ANNOUNCEMENT_REPEAT_COUNT; repeat += 1) {
const utterance = createUtterance(announcement);
utterance.onerror = (event) => {
if (event.error !== "canceled" && event.error !== "interrupted") setStatus("error");
};
synthesis.speak(utterance);
}
}
}, [status]);
return { announce, enable, status };
}

View File

@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { pollingResource } = vi.hoisted(() => ({
@@ -96,15 +96,27 @@ describe("DisplayPage", () => {
text: string;
lang = "";
rate = 1;
onstart: (() => void) | null = null;
onend: (() => void) | null = null;
onerror: ((event: { error: string }) => void) | null = null;
constructor(text: string) {
this.text = text;
}
}
vi.stubGlobal("SpeechSynthesisUtterance", SpeechSynthesisUtteranceMock);
vi.stubGlobal("speechSynthesis", { speak, cancel });
vi.stubGlobal("speechSynthesis", { speak, cancel, paused: false, resume: vi.fn() });
const { rerender } = render(<DisplayPage />);
fireEvent.click(screen.getByRole("button", { name: "开启叫号声音" }));
act(() => {
speak.mock.calls[0][0].onstart?.();
speak.mock.calls[0][0].onerror?.({ error: "interrupted" });
});
expect(screen.getByText("叫号声音已开启")).toBeVisible();
speak.mockClear();
cancel.mockClear();
pollingResource.data.current_batch = {
batch_number: 3,
status: "CALLED",

View File

@@ -1,7 +1,9 @@
import { useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { api } from "../api";
import { SpeechControl } from "../components/SpeechControl";
import { usePollingResource } from "../hooks/usePollingResource";
import { useSpeechAnnouncements } from "../hooks/useSpeechAnnouncements";
import { formatCallAnnouncement, formatDateTime, formatTicketNumberRange, isTimestampStale, projectStatusMeta } from "../lib/format";
import type { CallBatchDto } from "../types";
@@ -42,6 +44,7 @@ export function forecastRows(batch: CallBatchDto | null | undefined, waitingCoun
export function DisplayPage() {
const { token = "" } = useParams();
const { announce, enable: enableSpeech, status: speechStatus } = useSpeechAnnouncements();
const resource = usePollingResource((signal) => api.display(token, signal), {
enabled: Boolean(token),
intervalMs: 3_000,
@@ -64,18 +67,10 @@ export function DisplayPage() {
|| previous.token !== token
|| previous.key === currentCallKey
|| !currentAnnouncement
|| !("speechSynthesis" in window)
|| typeof SpeechSynthesisUtterance === "undefined"
) return;
window.speechSynthesis.cancel();
for (let repeat = 0; repeat < 3; repeat += 1) {
const utterance = new SpeechSynthesisUtterance(currentAnnouncement);
utterance.lang = "zh-CN";
utterance.rate = 0.9;
window.speechSynthesis.speak(utterance);
}
}, [currentAnnouncement, currentCallKey, hasSnapshot, token]);
announce([currentAnnouncement]);
}, [announce, currentAnnouncement, currentCallKey, hasSnapshot, token]);
const waitingTicketCount = data?.waiting_ticket_count ?? data?.waiting_count ?? 0;
const waitingPeopleCount = data?.waiting_people_count ?? 0;
const forecasts = data ? forecastRows(current, waitingTicketCount, data.estimated_wait) : [];
@@ -106,6 +101,7 @@ export function DisplayPage() {
return (
<main className="display-page">
<SpeechControl status={speechStatus} onEnable={enableSpeech} />
<header className="display-header">
<div className="display-brand">
<img src="/xiaoqikong-logo.jpg" alt="小七孔文旅集团" />

View File

@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { displayOverview, pollingResource } = vi.hoisted(() => ({
@@ -67,15 +67,24 @@ describe("PublicDisplayCenterPage", () => {
class SpeechSynthesisUtteranceMock {
text: string;
lang = "";
rate = 1;
onstart: (() => void) | null = null;
onend: (() => void) | null = null;
onerror: ((event: { error: string }) => void) | null = null;
constructor(text: string) {
this.text = text;
}
}
vi.stubGlobal("SpeechSynthesisUtterance", SpeechSynthesisUtteranceMock);
vi.stubGlobal("speechSynthesis", { speak, cancel: vi.fn() });
vi.stubGlobal("speechSynthesis", { speak, cancel: vi.fn(), paused: false, resume: vi.fn() });
const { rerender } = render(<PublicDisplayCenterPage />);
fireEvent.click(screen.getByRole("button", { name: "开启叫号声音" }));
act(() => speak.mock.calls[0][0].onstart?.());
expect(screen.getByText("叫号声音已开启")).toBeVisible();
speak.mockClear();
pollingResource.data = {
...pollingResource.data,
projects: [{

View File

@@ -1,7 +1,9 @@
import { useEffect, useRef } from "react";
import { api } from "../api";
import { FeedbackBanner, FreshnessBanner, LoadingState } from "../components/Feedback";
import { SpeechControl } from "../components/SpeechControl";
import { usePollingResource } from "../hooks/usePollingResource";
import { useSpeechAnnouncements } from "../hooks/useSpeechAnnouncements";
import { formatCallAnnouncement, isTimestampStale } from "../lib/format";
import type { CallBatchDto } from "../types";
import { DisplayCenter } from "./AdminPage";
@@ -11,6 +13,7 @@ function projectCurrentBatch(value: unknown): CallBatchDto | null {
}
export function PublicDisplayCenterPage() {
const { announce, enable: enableSpeech, status: speechStatus } = useSpeechAnnouncements();
const resource = usePollingResource((signal) => api.displayOverview(signal), { intervalMs: 5_000 });
const data = resource.data;
const stale = Boolean(data) && isTimestampStale(resource.lastClientSuccessAt, 30_000);
@@ -29,26 +32,13 @@ export function PublicDisplayCenterPage() {
if (announcement) announcements.push(`${project.name}${announcement}`);
}
lastCallsRef.current = nextCalls;
if (
!announcements.length
|| !("speechSynthesis" in window)
|| typeof SpeechSynthesisUtterance === "undefined"
) return;
window.speechSynthesis.cancel();
for (const announcement of announcements) {
for (let repeat = 0; repeat < 3; repeat += 1) {
const utterance = new SpeechSynthesisUtterance(announcement);
utterance.lang = "zh-CN";
utterance.rate = 0.9;
window.speechSynthesis.speak(utterance);
}
}
}, [data]);
announce(announcements);
}, [announce, data]);
return (
<div className="app-shell app-shell--admin app-shell--no-primary-action">
<a className="skip-link" href="#main-content"></a>
<SpeechControl status={speechStatus} onEnable={enableSpeech} />
<header className="app-header">
<div className="brand-lockup" aria-label="景区排队叫号系统">
<span className="brand-logo-frame" aria-hidden="true"><img className="brand-logo" src="/xiaoqikong-logo.jpg" alt="" /></span>

View File

@@ -2481,6 +2481,43 @@ tbody tr:hover {
font-weight: 700;
}
.speech-control {
position: fixed;
z-index: 60;
right: max(18px, env(safe-area-inset-right));
bottom: max(18px, env(safe-area-inset-bottom));
display: flex;
align-items: center;
gap: var(--space-4);
max-width: min(620px, calc(100vw - 36px));
border: 1px solid #fbbf24;
border-radius: var(--radius-card);
padding: var(--space-3) var(--space-4);
background: rgba(20, 28, 38, .96);
box-shadow: 0 16px 40px rgba(0, 0, 0, .28);
color: #fff;
}
.speech-control > div,
.speech-control--ready {
display: grid;
gap: 2px;
}
.speech-control span {
color: #dbe5ee;
font-size: .88rem;
}
.speech-control--ready {
border-color: #34d399;
background: rgba(5, 76, 52, .96);
}
.speech-control--error {
border-color: #fb7185;
}
.display-header {
grid-row: 1;
display: flex;
@@ -3850,17 +3887,24 @@ tbody tr:hover {
.display-clock { flex-direction: row; gap: 24px; align-items: center; }
.display-clock strong { color: #063b20; font-size: clamp(1.7rem, 2.8vw, 3rem); }
.display-status { border: 0; background: transparent; color: #086333; font-size: 1.2rem; }
.display-page:not(.display-page--state) { display: grid; grid-template-columns: minmax(0, 2.35fr) minmax(280px, .95fr); grid-template-rows: auto 1fr auto; gap: 16px 20px; }
.display-page:not(.display-page--state) {
--display-history-height: clamp(136px, 20vh, 170px);
display: grid;
grid-template-columns: minmax(0, 2.35fr) minmax(280px, .95fr);
grid-template-rows: auto minmax(0, 1fr);
gap: 16px 20px;
padding-bottom: calc(var(--display-history-height) + 34px);
}
.display-page .display-header { grid-column: 1 / -1; }
.display-current { grid-column: 1; min-height: 520px; border: 2px solid #075d31; border-radius: 16px; padding: 0 54px 28px; background: rgba(255,255,255,.9); box-shadow: none; }
.display-current > p { margin: 0 -54px 32px; border-radius: 13px 13px 0 0; padding: 24px 50px; background: linear-gradient(105deg,#064c26,#076735); color: #fff; font-size: clamp(1.8rem,3vw,3.5rem); font-weight: 900; }
.display-numbers { display: flex; flex-wrap: wrap; justify-content: center; align-content: center; gap: 24px 54px; min-height: 300px; }
.display-current { grid-column: 1; grid-row: 2; min-height: 0; border: 2px solid #075d31; border-radius: 16px; padding: 0 54px clamp(16px, 2.6vh, 28px); background: rgba(255,255,255,.9); box-shadow: none; }
.display-current > p { margin: 0 -54px clamp(12px, 2vh, 32px); border-radius: 13px 13px 0 0; padding: clamp(14px, 2.2vh, 24px) 50px; background: linear-gradient(105deg,#064c26,#076735); color: #fff; font-size: clamp(1.8rem,3vw,3.5rem); font-weight: 900; }
.display-numbers { display: flex; flex-wrap: wrap; justify-content: center; align-content: center; gap: 24px 54px; min-height: clamp(180px, 30vh, 300px); }
.display-numbers strong { color: #064624; font-size: clamp(4.4rem,7.3vw,8.5rem); line-height: .9; letter-spacing: .02em; text-shadow: none; }
.display-current__meta { justify-content: center; gap: 28px; border-top: 1px solid #b9cec0; padding-top: 24px; color: #173423; font-size: 1.35rem; }
.display-secondary { grid-column: 2; display: block; }
.display-current__meta { justify-content: center; gap: 28px; margin-top: clamp(12px, 2vh, 24px); border-top: 1px solid #b9cec0; padding-top: clamp(12px, 2vh, 24px); color: #173423; font-size: clamp(1rem, 1.5vw, 1.35rem); }
.display-secondary { grid-column: 2; grid-row: 2; min-height: 0; display: block; }
.display-queue-summary { min-height: 100%; border: 1px solid #c6d2ca; border-radius: 16px; padding: 28px 34px; background: rgba(255,255,255,.86); }
.display-forecast__heading { display: flex; align-items: baseline; justify-content: space-between; gap: 20px; border-bottom: 1px solid #d3ddd6; padding-bottom: 24px; }
.display-forecast__heading h2 { margin: 0; font-size: 1.7rem; }
.display-forecast__heading h2 { margin: 0; color: #0c2b1a; font-size: 1.7rem; }
.display-forecast__heading span { color: #607068; font-size: 1.1rem; font-weight: 800; }
.display-forecast { display: grid; margin: 0; padding: 0; list-style: none; }
.display-forecast li { display: grid; grid-template-columns: minmax(0,1.35fr) auto; align-items: baseline; gap: 18px; border-bottom: 1px solid #d3ddd6; padding: clamp(20px,3vh,34px) 0; animation: display-forecast-in .45s ease both; }
@@ -3874,8 +3918,7 @@ tbody tr:hover {
.display-queue-summary footer { display: flex; justify-content: space-between; align-items: baseline; gap: 18px; border-top: 0; margin-top: 20px; padding-top: 0; color: #53635a; }
.display-queue-summary footer strong { color: #07512b; font-size: clamp(1.1rem,1.5vw,1.45rem); }
.display-history { grid-column: 1 / -1; }
.display-secondary .display-history { position: fixed; right: 24px; bottom: 18px; left: 24px; height: 170px; border: 1px solid #c9d4cd; border-radius: 14px; padding: 18px 24px; background: rgba(255,255,255,.94); }
.display-page:not(.display-page--state) { padding-bottom: 204px; }
.display-secondary .display-history { position: fixed; right: 24px; bottom: 18px; left: 24px; height: var(--display-history-height); border: 1px solid #c9d4cd; border-radius: 14px; padding: 18px 24px; background: rgba(255,255,255,.94); }
.display-history h2 { margin-bottom: 12px; color: #0c2b1a; font-size: 1.35rem; }
.display-history ol { display: grid; grid-template-columns: repeat(5,1fr); }
.display-history li { display: grid; justify-items: center; gap: 6px; border-right: 1px solid #d6dfd9; }
@@ -3886,6 +3929,13 @@ tbody tr:hover {
@media (max-width: 900px) {
.display-page:not(.display-page--state) { display: block; padding: 14px; }
.display-header { display: grid; min-height: 0; gap: 12px; padding: 0 0 12px; }
.display-brand { justify-content: center; gap: 14px; }
.display-brand img { width: 112px; height: 48px; }
.display-brand h1 { padding-left: 14px; font-size: 1.35rem; }
.display-clock { width: 100%; flex-wrap: wrap; justify-content: center; gap: 8px 14px; }
.display-clock strong { font-size: 1.8rem; }
.display-clock time { padding-left: 14px; font-size: .9rem; }
.display-current { min-height: auto; padding: 0 18px 22px; }
.display-current > p { margin: 0 -18px 22px; padding: 18px; }
.display-secondary { display: grid; gap: 14px; margin-top: 14px; }
@@ -3984,9 +4034,10 @@ body { min-width: 0; }
/* Public display forecast overrides the legacy queue-summary metrics. */
.display-queue-summary .display-forecast { display: grid; margin: 0; padding: 0; list-style: none; }
.display-queue-summary .display-forecast li { display: grid; grid-template-columns: minmax(0, 1.35fr) auto; align-items: baseline; gap: 18px; border-bottom: 1px solid #d3ddd6; padding: clamp(20px, 3vh, 34px) 0; }
.display-queue-summary .display-forecast li strong { color: #063f22; font-size: clamp(1.7rem, 3vw, 3.4rem); letter-spacing: .02em; }
.display-queue-summary .display-forecast li span { color: #075d31; font-size: clamp(1.35rem, 2vw, 2.2rem); font-weight: 900; white-space: nowrap; }
.display-queue-summary footer { display: flex; justify-content: space-between; align-items: baseline; gap: 18px; border-top: 0; margin-top: 20px; padding-top: 0; }
.display-queue-summary .display-forecast li strong { color: #063f22; font-size: clamp(1.55rem, 2.2vw, 2.65rem); letter-spacing: .02em; }
.display-queue-summary .display-forecast li span { color: #075d31; font-size: clamp(1.2rem, 1.5vw, 1.65rem); font-weight: 900; white-space: nowrap; }
.display-queue-summary footer { display: grid; gap: 6px; align-items: start; border-top: 0; margin-top: 20px; padding-top: 0; }
.display-queue-summary footer span { white-space: nowrap; }
@media (max-width: 900px) {
.display-queue-summary .display-forecast li { grid-template-columns: 1fr auto; }