Compare commits
2 Commits
af2c18a546
...
d26d36c3d3
| Author | SHA1 | Date | |
|---|---|---|---|
| d26d36c3d3 | |||
| 6f3d14d314 |
10
findings.md
10
findings.md
@@ -609,3 +609,13 @@
|
||||
- 项目编码规则为 2–24 位大写字母、数字、下划线或连字符;可复用既有校验规则,并对 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 下页头切换为两行居中布局,日期和时间均落在视口内;内容使用普通纵向滚动,无横向溢出。
|
||||
|
||||
10
progress.md
10
progress.md
@@ -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 生产构建成功。
|
||||
|
||||
26
task_plan.md
26
task_plan.md
@@ -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` 直接启动 |
|
||||
|
||||
38
web/src/components/SpeechControl.tsx
Normal file
38
web/src/components/SpeechControl.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
web/src/hooks/useSpeechAnnouncements.ts
Normal file
63
web/src/hooks/useSpeechAnnouncements.ts
Normal 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 };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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="小七孔文旅集团" />
|
||||
|
||||
@@ -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: [{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user