336 lines
24 KiB
HTML
336 lines
24 KiB
HTML
<!doctype html>
|
||
<html lang="ja">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src 'self' file:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'none'; font-src 'none'; base-uri 'none'; form-action 'none'">
|
||
<title>DocView PDF 比較レビュー</title>
|
||
<style>
|
||
:root { color-scheme: light; font-family: system-ui, sans-serif; color: #202a35; background: #f4f5f7; }
|
||
* { box-sizing: border-box; }
|
||
body { margin: 0; }
|
||
header, main { max-width: 1600px; margin: auto; padding: 20px 24px; }
|
||
header { padding-bottom: 8px; }
|
||
h1 { font-size: 22px; margin: 0 0 8px; }
|
||
h2 { font-size: 18px; margin: 0; }
|
||
p { margin: 8px 0; line-height: 1.65; }
|
||
button, select, input, textarea { font: inherit; }
|
||
button, select, input[type="text"] { min-height: 38px; border: 1px solid #a9b3bd; border-radius: 4px; background: white; color: inherit; padding: 7px 10px; }
|
||
button { cursor: pointer; }
|
||
button:hover:not(:disabled) { background: #edf3fa; border-color: #396383; }
|
||
button:disabled { cursor: default; opacity: .45; }
|
||
:focus-visible { outline: 3px solid #287cbb; outline-offset: 3px; }
|
||
a { color: #175883; text-underline-offset: 3px; }
|
||
code { overflow-wrap: anywhere; font-size: 12px; }
|
||
.notice { padding: 10px 14px; background: #fff8df; border-left: 4px solid #a97816; }
|
||
.muted { color: #566575; font-size: 13px; }
|
||
.row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||
.spread { justify-content: space-between; }
|
||
.control { display: flex; align-items: center; gap: 8px; }
|
||
.panel { background: white; border: 1px solid #ced4db; border-radius: 6px; padding: 16px; margin-bottom: 16px; }
|
||
.case-control { flex: 1 1 380px; }
|
||
.case-control select { width: 100%; min-width: 0; }
|
||
#caseCounter { min-width: 58px; text-align: center; }
|
||
#reviewer { width: 220px; max-width: 100%; }
|
||
#save { background: #1e5b82; border-color: #1e5b82; color: white; }
|
||
#save:hover { background: #194b6b; }
|
||
#summary { margin: 10px 0 0; }
|
||
.toolbar { margin: 14px 0; }
|
||
#opacity { width: 160px; }
|
||
#opacityValue { min-width: 42px; }
|
||
.figures { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 14px; }
|
||
figure { margin: 0; min-width: 0; }
|
||
figcaption { font-weight: 600; margin-bottom: 7px; }
|
||
.viewport { border: 1px solid #c4cbd2; background: #e6e9ed; overflow: auto; max-height: 66vh; min-height: 140px; }
|
||
.viewport img { display: block; }
|
||
.viewport.fit > img { width: 100%; height: auto; }
|
||
.viewport.original > img { width: auto; height: auto; max-width: none; }
|
||
.image-meta { margin-top: 6px; font-size: 12px; color: #566575; overflow-wrap: anywhere; }
|
||
#overlayCanvas { position: relative; }
|
||
#overlayCanvas img { position: absolute; top: 0; left: 0; max-width: none; }
|
||
#differenceFigure { max-width: 1100px; margin: auto; }
|
||
#imageWarning, #formError { color: #9a251b; }
|
||
#imageWarning:empty, #formError:empty { display: none; }
|
||
#metrics, #referenceNotes { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||
ul { margin: 8px 0; padding-left: 24px; line-height: 1.7; }
|
||
.table-scroll { overflow-x: auto; }
|
||
table { width: 100%; border-collapse: collapse; font-size: 13px; line-height: 1.6; }
|
||
th, td { padding: 9px 10px; border: 1px solid #ced4db; text-align: left; vertical-align: top; }
|
||
th { background: #f4f5f7; font-weight: 600; }
|
||
td.coordinates { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||
details { margin-top: 10px; }
|
||
details summary { cursor: pointer; }
|
||
.hashes { display: grid; grid-template-columns: 130px minmax(0, 1fr); gap: 8px; margin-top: 10px; }
|
||
.decision { display: grid; grid-template-columns: minmax(190px, 260px) minmax(0, 1fr); gap: 18px; }
|
||
.field { display: flex; flex-direction: column; gap: 7px; }
|
||
textarea { width: 100%; min-height: 94px; resize: vertical; border: 1px solid #a9b3bd; border-radius: 4px; padding: 9px; color: inherit; background: white; }
|
||
#saveMessage { min-height: 20px; }
|
||
[hidden] { display: none !important; }
|
||
@media (max-width: 680px) {
|
||
header, main { padding-left: 12px; padding-right: 12px; }
|
||
.panel { padding: 12px; }
|
||
.decision { grid-template-columns: 1fr; }
|
||
.figures { gap: 8px; }
|
||
.hashes { grid-template-columns: 1fr; }
|
||
.viewport { max-height: 55vh; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>DocView PDF 比較レビュー</h1>
|
||
<p class="notice">人による確認前の比較資料です。JSON を保存するだけで、設計全体の合格や配布承認にはなりません。</p>
|
||
<p class="muted">画像と確認項目を見比べ、ケースごとに判定してください。入力内容はこの画面のメモリ内だけに保持されます。再読込・終了で消えるため、必要な結果は JSON 保存してください。</p>
|
||
</header>
|
||
<main id="app" hidden>
|
||
<section class="panel" aria-label="ケース選択とレビュー保存">
|
||
<div class="row spread">
|
||
<div class="row case-control">
|
||
<label for="caseSelect">ケース</label>
|
||
<select id="caseSelect" aria-label="比較するケース"></select>
|
||
</div>
|
||
<div class="row">
|
||
<button id="previous" type="button" aria-label="前のケース">前へ</button>
|
||
<span id="caseCounter" aria-live="polite"></span>
|
||
<button id="next" type="button" aria-label="次のケース">次へ</button>
|
||
</div>
|
||
</div>
|
||
<p id="summary" class="muted" aria-live="polite"></p>
|
||
<div class="row spread" style="margin-top:14px">
|
||
<div class="control"><label for="reviewer">確認者</label><input id="reviewer" type="text" maxlength="128" autocomplete="off" placeholder="氏名または識別名"></div>
|
||
<button id="save" type="button">レビューを JSON 保存</button>
|
||
</div>
|
||
<p id="formError" role="alert"></p>
|
||
<p id="saveMessage" class="muted" role="status"></p>
|
||
<details id="exportFallback" hidden>
|
||
<summary>保存するJSON(ダウンロードできない場合はコピー)</summary>
|
||
<label class="field" for="exportJson" style="margin-top:10px">保存するJSON(ダウンロードできない場合はコピー)
|
||
<textarea id="exportJson" readonly rows="12" spellcheck="false" aria-describedby="exportJsonHelp"></textarea>
|
||
</label>
|
||
<p id="exportJsonHelp" class="muted">欄内で全選択してコピーし、テキストファイルに .json 拡張子で保存できます。表示内容は保存ボタンを押した時点の判定です。</p>
|
||
</details>
|
||
</section>
|
||
|
||
<section class="panel" aria-labelledby="caseTitle">
|
||
<div class="row spread"><h2 id="caseTitle"></h2><a id="sourceLink" target="_blank" rel="noopener">元の PDF を開く</a></div>
|
||
<p id="caseGroup" class="muted"></p>
|
||
<div class="row toolbar">
|
||
<div class="control"><label for="mode">表示</label><select id="mode"><option value="parallel">左右に並べる</option><option value="overlay">重ね合わせ</option><option id="differenceOption" value="difference">差分画像</option></select></div>
|
||
<div class="control"><label for="scale">画像の大きさ</label><select id="scale"><option value="fit">幅に合わせる</option><option value="original">原寸(画像 1 px = CSS 1 px)</option></select></div>
|
||
<div id="opacityControl" class="control" hidden><label for="opacity">右画像の不透明度</label><input id="opacity" type="range" min="0" max="100" value="50"><output id="opacityValue" for="opacity">50%</output></div>
|
||
</div>
|
||
<div id="parallel" class="figures">
|
||
<figure><figcaption id="leftLabel"></figcaption><div id="leftViewport" class="viewport fit" tabindex="0" aria-label="左の比較画像。原寸ではスクロールできます"><img id="leftImage" alt=""></div><div id="leftMeta" class="image-meta"></div></figure>
|
||
<figure><figcaption id="rightLabel"></figcaption><div id="rightViewport" class="viewport fit" tabindex="0" aria-label="右の比較画像。原寸ではスクロールできます"><img id="rightImage" alt=""></div><div id="rightMeta" class="image-meta"></div></figure>
|
||
</div>
|
||
<figure id="overlayFigure" hidden><figcaption id="overlayLabel"></figcaption><div id="overlayViewport" class="viewport" tabindex="0" aria-label="重ね合わせ画像"><div id="overlayCanvas"><img id="overlayLeft" alt="左画像"><img id="overlayRight" alt="右画像"></div></div><p class="muted">両画像の左上をそろえ、同じ倍率で重ねます。右画像の不透明度を変えて差を確認できます。</p></figure>
|
||
<figure id="differenceFigure" hidden><figcaption>差分画像</figcaption><div id="differenceViewport" class="viewport fit" tabindex="0" aria-label="差分画像"><img id="differenceImage" alt="左右画像の差分"></div><div id="differenceMeta" class="image-meta"></div></figure>
|
||
<p id="imageWarning" role="status"></p>
|
||
</section>
|
||
|
||
<section class="panel" aria-labelledby="checksTitle">
|
||
<h2 id="checksTitle">確認項目と比較条件</h2>
|
||
<ul id="checks"></ul>
|
||
<div id="regions" hidden>
|
||
<p id="regionCoordinateNote" class="muted">原寸画像の左上を (0,0) とした px 座標。枠線の太さを含まない注釈矩形。</p>
|
||
<div class="table-scroll"><table aria-label="確認対象の位置と表示期待" aria-describedby="regionCoordinateNote"><thead><tr><th scope="col">対象</th><th scope="col">位置 [x0, y0, x1, y1]</th><th scope="col">表示の期待</th><th scope="col">Poppler 比較可否</th></tr></thead><tbody id="regionRows"></tbody></table></div>
|
||
</div>
|
||
<p id="referenceNotes"></p>
|
||
<p id="metrics" class="muted"></p>
|
||
<details><summary>資料と画像の SHA-256</summary><div id="hashes" class="hashes"></div></details>
|
||
</section>
|
||
|
||
<section class="panel" aria-labelledby="decisionTitle">
|
||
<h2 id="decisionTitle" style="margin-bottom:12px">このケースの判定</h2>
|
||
<div class="decision">
|
||
<div class="field"><label for="status">確認状態</label><select id="status"><option value="unreviewed">未確認</option><option value="accepted">問題なし</option><option value="accepted_difference">差分許容</option><option value="needs_fix">要修正</option></select><span class="muted">初期値は未確認です。自動判定は行いません。</span></div>
|
||
<div class="field"><label for="notes">理由・確認メモ <span id="reasonRequired" class="muted">(差分許容では必須)</span></label><textarea id="notes" maxlength="4096" aria-describedby="reasonRequired" placeholder="差の場所、許容する理由、修正が必要な点など"></textarea></div>
|
||
</div>
|
||
</section>
|
||
<p class="muted">Alt + ← / → でもケースを切り替えられます。保存 JSON の全体承認状態は「未承認」のままです。資料のハッシュは生成時の値で、この画面でファイルの再ハッシュは行いません。</p>
|
||
</main>
|
||
<p id="loadError" class="notice" role="alert" hidden></p>
|
||
<noscript><p class="notice">このローカル比較画面には JavaScript が必要です。</p></noscript>
|
||
<script id="review-data" type="application/json">__REVIEW_DATA__</script>
|
||
<script>
|
||
(() => {
|
||
"use strict";
|
||
const element = id => document.getElementById(id);
|
||
const labels = {unreviewed: "未確認", accepted: "問題なし", accepted_difference: "差分許容", needs_fix: "要修正"};
|
||
const hashValid = value => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
||
function localUrl(value) {
|
||
if (typeof value !== "string" || !value || /^[\\/]/.test(value) || /[\\\u0000-\u001f]/.test(value) || /^[a-z][a-z0-9+.-]*:/i.test(value))
|
||
throw new Error("画像と原本のリンクにはローカル相対 URL が必要です。");
|
||
return value;
|
||
}
|
||
function asset(value) {
|
||
if (!value || !hashValid(value.sha256)) throw new Error("資料の SHA-256 が不正です。");
|
||
localUrl(value.url);
|
||
}
|
||
let data;
|
||
try {
|
||
data = JSON.parse(element("review-data").textContent);
|
||
if (data.schemaVersion !== 1 || !hashValid(data.bundleSha256) || !hashValid(data.workerSha256) || !Array.isArray(data.cases) || !data.cases.length || data.cases.length > 500)
|
||
throw new Error("比較資料の形式または件数が不正です。");
|
||
const ids = new Set();
|
||
for (const item of data.cases) {
|
||
if (typeof item.id !== "string" || !item.id || ids.has(item.id) || !Array.isArray(item.checks) || item.checks.some(value => typeof value !== "string"))
|
||
throw new Error("ケースの識別子または確認項目が不正です。");
|
||
ids.add(item.id);
|
||
asset(item.source); asset(item.left); asset(item.right);
|
||
if (item.difference !== null) asset(item.difference);
|
||
if (item.independentReference !== undefined && typeof item.independentReference !== "boolean")
|
||
throw new Error("独立参照の指定が不正です。");
|
||
if (item.regions !== undefined && (!Array.isArray(item.regions) || item.regions.length > 256 || item.regions.some(region =>
|
||
!region || typeof region.name !== "string" || typeof region.expectation !== "string" || typeof region.referenceSupported !== "boolean" ||
|
||
!Array.isArray(region.rect) || region.rect.length !== 4 || region.rect.some(value => typeof value !== "number" || !Number.isFinite(value)) ||
|
||
region.rect[2] < region.rect[0] || region.rect[3] < region.rect[1])))
|
||
throw new Error("確認対象の矩形または表示期待が不正です。");
|
||
}
|
||
} catch (error) {
|
||
element("loadError").textContent = "比較資料を読み込めませんでした。" + error.message;
|
||
element("loadError").hidden = false;
|
||
return;
|
||
}
|
||
|
||
const reviews = data.cases.map(() => ({status: "unreviewed", notes: ""}));
|
||
let current = 0;
|
||
const select = element("caseSelect");
|
||
data.cases.forEach((item, index) => {
|
||
const option = document.createElement("option");
|
||
option.value = String(index);
|
||
select.append(option);
|
||
});
|
||
function text(id, value) { element(id).textContent = value == null ? "" : String(value); }
|
||
function updateSummary() {
|
||
const counts = {unreviewed: 0, accepted: 0, accepted_difference: 0, needs_fix: 0};
|
||
reviews.forEach((review, index) => {
|
||
++counts[review.status];
|
||
const item = data.cases[index];
|
||
select.options[index].textContent = `${index + 1}. [${labels[review.status]}] ${item.group} / ${item.title}`;
|
||
});
|
||
text("summary", Object.keys(counts).map(key => `${labels[key]} ${counts[key]} 件`).join(" / "));
|
||
}
|
||
function imageMetadata() {
|
||
for (const side of ["left", "right", "difference"]) {
|
||
const image = element(side + "Image");
|
||
text(side + "Meta", image.naturalWidth ? `${image.naturalWidth} × ${image.naturalHeight} px` : "");
|
||
}
|
||
const left = element("leftImage"), right = element("rightImage");
|
||
if (left.naturalWidth && right.naturalWidth && (left.naturalWidth !== right.naturalWidth || left.naturalHeight !== right.naturalHeight))
|
||
text("imageWarning", "左右の画像寸法が異なります。重ね合わせでは同じ倍率を保ち、画像を引き伸ばして一致させません。");
|
||
layoutOverlay();
|
||
}
|
||
function layoutOverlay() {
|
||
const left = element("overlayLeft"), right = element("overlayRight");
|
||
const width = Math.max(left.naturalWidth, right.naturalWidth), height = Math.max(left.naturalHeight, right.naturalHeight);
|
||
if (!width || !height || element("overlayFigure").hidden) return;
|
||
const scale = element("scale").value === "fit" ? element("overlayViewport").clientWidth / width : 1;
|
||
const canvas = element("overlayCanvas");
|
||
canvas.style.width = `${width * scale}px`; canvas.style.height = `${height * scale}px`;
|
||
for (const image of [left, right]) {
|
||
image.style.width = `${image.naturalWidth * scale}px`;
|
||
image.style.height = `${image.naturalHeight * scale}px`;
|
||
}
|
||
}
|
||
function updateView() {
|
||
const mode = element("mode").value, scale = element("scale").value;
|
||
element("parallel").hidden = mode !== "parallel";
|
||
element("overlayFigure").hidden = mode !== "overlay";
|
||
element("differenceFigure").hidden = mode !== "difference";
|
||
element("opacityControl").hidden = mode !== "overlay";
|
||
for (const side of ["left", "right", "difference"]) element(side + "Viewport").className = "viewport " + scale;
|
||
element("overlayRight").style.opacity = String(Number(element("opacity").value) / 100);
|
||
text("opacityValue", element("opacity").value + "%");
|
||
layoutOverlay();
|
||
}
|
||
function show(index) {
|
||
current = Math.max(0, Math.min(data.cases.length - 1, index));
|
||
const item = data.cases[current], review = reviews[current];
|
||
select.value = String(current);
|
||
element("previous").disabled = current === 0;
|
||
element("next").disabled = current === data.cases.length - 1;
|
||
text("caseCounter", `${current + 1} / ${data.cases.length}`);
|
||
text("caseTitle", item.title); text("caseGroup", `${item.group} · ${item.id}`);
|
||
text("leftLabel", "左 · " + item.left.label); text("rightLabel", "右 · " + item.right.label);
|
||
text("overlayLabel", `${item.left.label} + ${item.right.label}`);
|
||
element("sourceLink").href = localUrl(item.source.url);
|
||
element("status").value = review.status; element("notes").value = review.notes;
|
||
element("notes").required = review.status === "accepted_difference";
|
||
text("referenceNotes", item.notes); text("metrics", item.metrics); text("imageWarning", "");
|
||
const checks = element("checks"); checks.replaceChildren();
|
||
for (const value of item.checks) { const li = document.createElement("li"); li.textContent = value; checks.append(li); }
|
||
const regionRows = element("regionRows"); regionRows.replaceChildren();
|
||
element("regions").hidden = !item.regions || item.regions.length === 0;
|
||
for (const region of item.regions || []) {
|
||
const row = document.createElement("tr");
|
||
const values = [region.name, "[" + region.rect.join(", ") + "]", region.expectation, region.referenceSupported ? "比較可" : "比較対象外"];
|
||
values.forEach((value, index) => { const cell = document.createElement("td"); cell.textContent = value; if (index === 1) cell.className = "coordinates"; row.append(cell); });
|
||
regionRows.append(row);
|
||
}
|
||
const hashes = element("hashes"); hashes.replaceChildren();
|
||
const rows = [["Bundle", data.bundleSha256], ["Worker", data.workerSha256], ["原本", item.source.sha256], ["左画像", item.left.sha256], ["右画像", item.right.sha256]];
|
||
if (item.difference) rows.push(["差分画像", item.difference.sha256]);
|
||
for (const [name, hash] of rows) { const label = document.createElement("span"), code = document.createElement("code"); label.textContent = name; code.textContent = hash; hashes.append(label, code); }
|
||
for (const [id, value] of [["leftImage", item.left], ["rightImage", item.right], ["overlayLeft", item.left], ["overlayRight", item.right]]) {
|
||
const image = element(id); image.alt = value.label; image.src = localUrl(value.url);
|
||
}
|
||
element("differenceOption").disabled = !item.difference;
|
||
if (item.difference) element("differenceImage").src = localUrl(item.difference.url);
|
||
else { element("differenceImage").removeAttribute("src"); if (element("mode").value === "difference") element("mode").value = "parallel"; }
|
||
for (const side of ["left", "right", "difference"]) text(side + "Meta", "");
|
||
for (const id of ["leftViewport", "rightViewport", "overlayViewport", "differenceViewport"]) { element(id).scrollTop = 0; element(id).scrollLeft = 0; }
|
||
updateView();
|
||
}
|
||
for (const id of ["leftImage", "rightImage", "overlayLeft", "overlayRight", "differenceImage"]) {
|
||
element(id).addEventListener("load", imageMetadata);
|
||
element(id).addEventListener("error", () => { if (element(id).hasAttribute("src")) text("imageWarning", "画像を開けません。資料のフォルダー構成と画像ファイルを確認してください。"); });
|
||
}
|
||
select.addEventListener("change", () => show(Number(select.value)));
|
||
element("previous").addEventListener("click", () => show(current - 1));
|
||
element("next").addEventListener("click", () => show(current + 1));
|
||
for (const id of ["mode", "scale"]) element(id).addEventListener("change", updateView);
|
||
element("opacity").addEventListener("input", updateView);
|
||
element("status").addEventListener("change", () => {
|
||
reviews[current].status = element("status").value;
|
||
element("notes").required = reviews[current].status === "accepted_difference";
|
||
text("formError", ""); text("saveMessage", "未保存の変更があります。"); updateSummary();
|
||
});
|
||
element("notes").addEventListener("input", () => { reviews[current].notes = element("notes").value; text("formError", ""); text("saveMessage", "未保存の変更があります。"); });
|
||
element("reviewer").addEventListener("input", () => { text("formError", ""); text("saveMessage", "未保存の変更があります。"); });
|
||
element("save").addEventListener("click", () => {
|
||
text("formError", "");
|
||
const reviewer = element("reviewer").value.trim();
|
||
if (!reviewer) { text("formError", "保存する前に確認者名を入力してください。"); element("reviewer").focus(); return; }
|
||
const missing = reviews.findIndex(value => value.status === "accepted_difference" && !value.notes.trim());
|
||
if (missing >= 0) { show(missing); text("formError", "差分を許容する理由を入力してください。"); element("notes").focus(); return; }
|
||
const copyAsset = value => value ? {url: value.url, sha256: value.sha256, ...(value.label ? {label: value.label} : {})} : null;
|
||
const record = {schemaVersion: 1, bundleSha256: data.bundleSha256, workerSha256: data.workerSha256,
|
||
reviewer, savedAt: new Date().toISOString(), overallApproval: "not-approved",
|
||
notice: "各ケースの人による判定を保存した記録。設計全体の合格・配布承認を意味しない。",
|
||
cases: data.cases.map((item, index) => ({id: item.id, group: item.group, title: item.title,
|
||
independentReference: item.independentReference === undefined ? null : item.independentReference,
|
||
status: reviews[index].status, notes: reviews[index].notes.trim(), source: copyAsset(item.source),
|
||
left: copyAsset(item.left), right: copyAsset(item.right), difference: copyAsset(item.difference)}))};
|
||
const json = JSON.stringify(record, null, 2) + "\n";
|
||
element("exportJson").value = json;
|
||
element("exportFallback").hidden = false;
|
||
element("exportFallback").open = true;
|
||
const url = URL.createObjectURL(new Blob([json], {type: "application/json;charset=utf-8"}));
|
||
const link = document.createElement("a"); link.href = url; link.download = `docview-pdf-review-${data.bundleSha256.slice(0, 12)}.json`;
|
||
document.body.append(link); link.click(); link.remove();
|
||
setTimeout(() => URL.revokeObjectURL(url), 30000);
|
||
text("saveMessage", "JSON の保存を開始しました。保存先はブラウザーで確認してください。全体承認状態は未承認です。");
|
||
});
|
||
document.addEventListener("keydown", event => {
|
||
if (event.altKey && !event.ctrlKey && !event.metaKey && (event.key === "ArrowLeft" || event.key === "ArrowRight")) {
|
||
event.preventDefault(); show(current + (event.key === "ArrowRight" ? 1 : -1));
|
||
}
|
||
});
|
||
new ResizeObserver(layoutOverlay).observe(element("overlayViewport"));
|
||
element("app").hidden = false; updateSummary(); show(0);
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|