7076 lines
262 KiB
HTML
7076 lines
262 KiB
HTML
<!doctype html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>exxTableReader</title>
|
|
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
|
|
<style>
|
|
:root {
|
|
--bg: #f4f6f8;
|
|
--panel: #ffffff;
|
|
--panel-soft: #f8fafc;
|
|
--line: #d9e0e7;
|
|
--line-strong: #b9c4cf;
|
|
--text: #17212b;
|
|
--muted: #617182;
|
|
--accent: #006d77;
|
|
--accent-strong: #00515a;
|
|
--accent-soft: #dff3f3;
|
|
--changed: #fff1c7;
|
|
--added: #dcfce7;
|
|
--removed: #ffe4e6;
|
|
--shadow: 0 18px 48px rgba(23, 33, 43, .08);
|
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
|
|
body {
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
color: var(--text);
|
|
background:
|
|
linear-gradient(180deg, #eef3f6 0%, var(--bg) 38%, #eef2f4 100%);
|
|
}
|
|
|
|
button, input, select, textarea {
|
|
font: inherit;
|
|
}
|
|
|
|
.app-shell {
|
|
min-height: 100vh;
|
|
display: grid;
|
|
grid-template-rows: auto 1fr;
|
|
}
|
|
|
|
header {
|
|
border-bottom: 1px solid var(--line);
|
|
background: rgba(255, 255, 255, .82);
|
|
backdrop-filter: blur(18px);
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 10;
|
|
}
|
|
|
|
.topbar {
|
|
max-width: none;
|
|
margin: 0 auto;
|
|
padding: 14px 20px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
}
|
|
|
|
.brand {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
min-width: 220px;
|
|
}
|
|
|
|
.mark {
|
|
width: 38px;
|
|
height: 38px;
|
|
border-radius: 8px;
|
|
display: grid;
|
|
place-items: center;
|
|
color: white;
|
|
background: linear-gradient(135deg, var(--accent), #2a9d8f);
|
|
box-shadow: 0 10px 24px rgba(0, 109, 119, .22);
|
|
font-weight: 800;
|
|
letter-spacing: 0;
|
|
}
|
|
|
|
h1 {
|
|
margin: 0;
|
|
font-size: 18px;
|
|
line-height: 1.15;
|
|
letter-spacing: 0;
|
|
}
|
|
|
|
.subtitle {
|
|
margin-top: 2px;
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.btn, .icon-btn, .select {
|
|
border: 1px solid var(--line-strong);
|
|
background: var(--panel);
|
|
color: var(--text);
|
|
min-height: 36px;
|
|
border-radius: 7px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.btn {
|
|
padding: 0 12px;
|
|
font-weight: 650;
|
|
}
|
|
|
|
.btn.primary {
|
|
border-color: var(--accent);
|
|
color: #fff;
|
|
background: var(--accent);
|
|
}
|
|
|
|
.btn.primary:hover { background: var(--accent-strong); }
|
|
.btn:hover, .icon-btn:hover, .select:hover { border-color: var(--accent); }
|
|
|
|
.icon-btn {
|
|
width: 36px;
|
|
padding: 0;
|
|
}
|
|
|
|
.select {
|
|
padding: 0 10px;
|
|
}
|
|
|
|
main {
|
|
max-width: none;
|
|
width: 100%;
|
|
margin: 0 auto;
|
|
padding: 18px 20px 26px;
|
|
display: grid;
|
|
grid-template-columns: clamp(240px, 18vw, 300px) minmax(0, 1fr);
|
|
gap: 16px;
|
|
}
|
|
|
|
aside, .workspace {
|
|
min-width: 0;
|
|
}
|
|
|
|
.panel {
|
|
background: var(--panel);
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
box-shadow: var(--shadow);
|
|
}
|
|
|
|
.drop-zone {
|
|
min-height: 236px;
|
|
padding: 18px;
|
|
border: 1px dashed var(--line-strong);
|
|
border-radius: 8px;
|
|
background: var(--panel-soft);
|
|
display: grid;
|
|
align-content: center;
|
|
justify-items: center;
|
|
text-align: center;
|
|
gap: 12px;
|
|
transition: border-color .15s ease, background .15s ease, transform .15s ease;
|
|
}
|
|
|
|
.drop-zone.dragover {
|
|
border-color: var(--accent);
|
|
background: var(--accent-soft);
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.drop-icon {
|
|
width: 58px;
|
|
height: 58px;
|
|
color: var(--accent);
|
|
}
|
|
|
|
.drop-zone strong {
|
|
display: block;
|
|
font-size: 17px;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.drop-zone span {
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
line-height: 1.45;
|
|
}
|
|
|
|
.hidden-input {
|
|
position: absolute;
|
|
inline-size: 1px;
|
|
block-size: 1px;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.side-section {
|
|
margin-top: 14px;
|
|
padding: 14px;
|
|
}
|
|
|
|
.side-section h2, .workspace h2 {
|
|
margin: 0 0 10px;
|
|
font-size: 13px;
|
|
letter-spacing: .04em;
|
|
text-transform: uppercase;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.meta-list {
|
|
display: grid;
|
|
gap: 9px;
|
|
}
|
|
|
|
.meta-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.meta-row b {
|
|
color: var(--text);
|
|
text-align: right;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.search-row {
|
|
display: grid;
|
|
grid-template-columns: 1fr auto;
|
|
gap: 8px;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.input {
|
|
width: 100%;
|
|
min-height: 36px;
|
|
border: 1px solid var(--line-strong);
|
|
border-radius: 7px;
|
|
padding: 0 10px;
|
|
background: #fff;
|
|
color: var(--text);
|
|
}
|
|
|
|
.sheet-list {
|
|
display: grid;
|
|
gap: 6px;
|
|
}
|
|
|
|
.sheet-btn {
|
|
width: 100%;
|
|
min-height: 34px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 7px;
|
|
background: #fff;
|
|
color: var(--text);
|
|
text-align: left;
|
|
padding: 7px 9px;
|
|
cursor: pointer;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
}
|
|
|
|
.sheet-btn.active {
|
|
border-color: var(--accent);
|
|
background: var(--accent-soft);
|
|
color: var(--accent-strong);
|
|
font-weight: 700;
|
|
}
|
|
|
|
.workspace {
|
|
display: grid;
|
|
grid-template-rows: auto minmax(0, 1fr);
|
|
gap: 14px;
|
|
min-height: calc(100vh - 112px);
|
|
}
|
|
|
|
.summary-strip {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
|
gap: 10px;
|
|
}
|
|
|
|
.stat {
|
|
border: 1px solid var(--line);
|
|
background: var(--panel);
|
|
border-radius: 8px;
|
|
padding: 12px;
|
|
min-height: 74px;
|
|
}
|
|
|
|
.stat span {
|
|
display: block;
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
text-transform: uppercase;
|
|
letter-spacing: .04em;
|
|
margin-bottom: 7px;
|
|
}
|
|
|
|
.stat strong {
|
|
font-size: 23px;
|
|
line-height: 1;
|
|
}
|
|
|
|
.viewer {
|
|
min-height: 0;
|
|
display: grid;
|
|
grid-template-rows: auto minmax(0, 1fr);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.viewer-head {
|
|
min-height: 52px;
|
|
border-bottom: 1px solid var(--line);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
padding: 10px 12px;
|
|
background: var(--panel);
|
|
}
|
|
|
|
.viewer-title {
|
|
min-width: 0;
|
|
}
|
|
|
|
.viewer-title strong {
|
|
display: block;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.viewer-title span {
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.viewer-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.view-tabs,
|
|
.header-export-row {
|
|
display: inline-flex;
|
|
border: 1px solid var(--line-strong);
|
|
border-radius: 7px;
|
|
overflow: hidden;
|
|
background: #fff;
|
|
}
|
|
|
|
.header-export-row .btn {
|
|
min-height: 32px;
|
|
border: 0;
|
|
border-right: 1px solid var(--line);
|
|
border-radius: 0;
|
|
background: transparent;
|
|
color: var(--muted);
|
|
padding: 0 10px;
|
|
margin: 0;
|
|
}
|
|
|
|
.header-export-row .btn:last-child { border-right: 0; }
|
|
.header-export-row .btn:hover { color: var(--text); }
|
|
|
|
.tab-btn {
|
|
min-height: 32px;
|
|
border: 0;
|
|
border-right: 1px solid var(--line);
|
|
background: transparent;
|
|
color: var(--muted);
|
|
padding: 0 10px;
|
|
cursor: pointer;
|
|
font-weight: 650;
|
|
}
|
|
|
|
.tab-btn:last-child { border-right: 0; }
|
|
.tab-btn.active {
|
|
color: #fff;
|
|
background: var(--accent);
|
|
}
|
|
|
|
.viewer-body {
|
|
min-height: 0;
|
|
overflow: auto;
|
|
background: #fff;
|
|
}
|
|
|
|
.record-card, .field-card, .profile-card {
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
background: var(--panel);
|
|
}
|
|
|
|
|
|
.summary-line {
|
|
color: var(--muted);
|
|
line-height: 1.5;
|
|
margin: 0;
|
|
}
|
|
|
|
|
|
.distribution-list { display: grid; gap: 8px; }
|
|
|
|
.distribution-row {
|
|
display: grid;
|
|
grid-template-columns: minmax(90px, 1fr) minmax(100px, 2fr) auto;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.bar {
|
|
height: 8px;
|
|
border-radius: 999px;
|
|
background: #e7edf2;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.bar span {
|
|
display: block;
|
|
height: 100%;
|
|
background: var(--accent);
|
|
}
|
|
|
|
.records-layout {
|
|
height: 100%;
|
|
min-height: 520px;
|
|
display: grid;
|
|
grid-template-columns: minmax(220px, 24%) minmax(0, 1fr);
|
|
}
|
|
|
|
.record-list {
|
|
border-right: 1px solid var(--line);
|
|
overflow: auto;
|
|
background: #f7fafc;
|
|
}
|
|
|
|
.record-card {
|
|
width: calc(100% - 20px);
|
|
margin: 10px;
|
|
padding: 11px;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
display: grid;
|
|
gap: 7px;
|
|
}
|
|
|
|
.record-card.active {
|
|
border-color: var(--accent);
|
|
box-shadow: 0 0 0 2px var(--accent-soft);
|
|
}
|
|
|
|
.record-title {
|
|
color: var(--text);
|
|
font-weight: 800;
|
|
line-height: 1.35;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.record-meta {
|
|
display: flex;
|
|
gap: 6px;
|
|
flex-wrap: wrap;
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.record-detail {
|
|
min-width: 0;
|
|
overflow: auto;
|
|
background: #fff;
|
|
}
|
|
|
|
.record-article {
|
|
max-width: none;
|
|
margin: 0 auto;
|
|
padding: 40px clamp(36px, 5vw, 84px) 64px;
|
|
}
|
|
|
|
.article-kicker {
|
|
color: var(--accent-strong);
|
|
font-size: 12px;
|
|
font-weight: 800;
|
|
letter-spacing: .08em;
|
|
text-transform: uppercase;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.record-article h2 {
|
|
margin: 0;
|
|
color: var(--text);
|
|
font-size: 30px;
|
|
line-height: 1.16;
|
|
letter-spacing: 0;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.article-meta {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
margin: 16px 0 22px;
|
|
}
|
|
|
|
.article-lead {
|
|
margin: 0 0 28px;
|
|
color: #344452;
|
|
font-size: 18px;
|
|
line-height: 1.62;
|
|
border-left: 4px solid var(--accent);
|
|
padding-left: 16px;
|
|
}
|
|
|
|
.article-section {
|
|
padding: 22px 0;
|
|
border-top: 1px solid var(--line);
|
|
}
|
|
|
|
.article-section h3 {
|
|
margin: 0 0 12px;
|
|
color: var(--text);
|
|
font-size: 19px;
|
|
line-height: 1.25;
|
|
}
|
|
|
|
.article-section p {
|
|
margin: 0 0 12px;
|
|
color: #17212b;
|
|
font-size: 16px;
|
|
line-height: 1.72;
|
|
white-space: pre-wrap;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.article-section p:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.field-name {
|
|
display: block;
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
font-weight: 800;
|
|
letter-spacing: .05em;
|
|
text-transform: uppercase;
|
|
margin-bottom: 3px;
|
|
}
|
|
|
|
.article-facts {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
|
gap: 12px 18px;
|
|
margin: 4px 0 0;
|
|
}
|
|
|
|
.article-fact {
|
|
padding: 0 0 0 12px;
|
|
border-left: 2px solid var(--line-strong);
|
|
}
|
|
|
|
.article-fact b {
|
|
display: block;
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
letter-spacing: .05em;
|
|
text-transform: uppercase;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.article-fact span {
|
|
display: block;
|
|
color: var(--text);
|
|
font-size: 16px;
|
|
line-height: 1.55;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.profile-grid {
|
|
padding: 16px;
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
|
gap: 12px;
|
|
}
|
|
|
|
.profile-card {
|
|
padding: 13px;
|
|
display: grid;
|
|
gap: 9px;
|
|
}
|
|
|
|
.profile-card h3 {
|
|
margin: 0;
|
|
font-size: 15px;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.profile-meta {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 8px;
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.profile-meta b {
|
|
display: block;
|
|
color: var(--text);
|
|
font-size: 15px;
|
|
margin-top: 2px;
|
|
}
|
|
|
|
mark {
|
|
background: var(--changed);
|
|
color: inherit;
|
|
padding: 0 2px;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
border-collapse: separate;
|
|
border-spacing: 0;
|
|
font-size: 13px;
|
|
}
|
|
|
|
th, td {
|
|
border-right: 1px solid var(--line);
|
|
border-bottom: 1px solid var(--line);
|
|
padding: 7px 9px;
|
|
vertical-align: top;
|
|
max-width: 360px;
|
|
min-width: 90px;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
th {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 2;
|
|
background: #eef3f6;
|
|
color: #344452;
|
|
text-align: left;
|
|
font-weight: 750;
|
|
}
|
|
|
|
tbody tr:nth-child(even) td { background: #fbfcfd; }
|
|
tbody tr:hover td { background: var(--accent-soft); }
|
|
|
|
.row-index {
|
|
position: sticky;
|
|
left: 0;
|
|
z-index: 1;
|
|
min-width: 54px;
|
|
max-width: 54px;
|
|
text-align: right;
|
|
color: var(--muted);
|
|
background: #f2f5f7;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
th.row-index {
|
|
z-index: 3;
|
|
background: #e4ebf0;
|
|
}
|
|
|
|
pre {
|
|
margin: 0;
|
|
padding: 16px;
|
|
font: 12px/1.55 "Cascadia Code", Consolas, monospace;
|
|
white-space: pre-wrap;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.viewer-scroll pre {
|
|
font-size: 20px;
|
|
line-height: 1.55;
|
|
}
|
|
|
|
.tree {
|
|
margin: 0;
|
|
padding: 14px 18px;
|
|
font: 13px/1.55 "Cascadia Code", Consolas, monospace;
|
|
}
|
|
|
|
.tree details {
|
|
margin-left: 16px;
|
|
}
|
|
|
|
.tree summary {
|
|
cursor: pointer;
|
|
color: var(--accent-strong);
|
|
font-weight: 700;
|
|
}
|
|
|
|
.tree .scalar {
|
|
margin-left: 18px;
|
|
color: #273642;
|
|
}
|
|
|
|
.tree .key {
|
|
color: #7c3aed;
|
|
}
|
|
|
|
.tree .value {
|
|
color: #0f766e;
|
|
}
|
|
|
|
.empty-state {
|
|
height: 100%;
|
|
min-height: 360px;
|
|
display: grid;
|
|
place-items: center;
|
|
padding: 26px;
|
|
color: var(--muted);
|
|
text-align: center;
|
|
}
|
|
|
|
.empty-state svg {
|
|
width: 78px;
|
|
height: 78px;
|
|
color: #9aa8b5;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.notice {
|
|
margin-top: 10px;
|
|
border: 1px solid #f3d08f;
|
|
background: #fff8e7;
|
|
color: #6b4e11;
|
|
border-radius: 7px;
|
|
padding: 10px;
|
|
font-size: 13px;
|
|
line-height: 1.45;
|
|
}
|
|
|
|
.pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
min-height: 24px;
|
|
padding: 0 8px;
|
|
border-radius: 999px;
|
|
color: var(--accent-strong);
|
|
background: var(--accent-soft);
|
|
font-size: 12px;
|
|
font-weight: 750;
|
|
}
|
|
|
|
@media (max-width: 980px) {
|
|
main {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.workspace {
|
|
min-height: 640px;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 680px) {
|
|
.topbar {
|
|
align-items: flex-start;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.toolbar {
|
|
width: 100%;
|
|
justify-content: flex-start;
|
|
}
|
|
|
|
main {
|
|
padding: 12px;
|
|
}
|
|
|
|
.summary-strip {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
|
|
.viewer-head {
|
|
align-items: stretch;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.view-tabs {
|
|
width: 100%;
|
|
}
|
|
|
|
.tab-btn {
|
|
flex: 1;
|
|
}
|
|
.records-layout {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.record-list {
|
|
border-right: 0;
|
|
border-bottom: 1px solid var(--line);
|
|
max-height: 320px;
|
|
}
|
|
}
|
|
/* exxReader-inspired dark reader layout */
|
|
:root {
|
|
--bg: #171717;
|
|
--panel: #1e1e1f;
|
|
--panel-soft: #242426;
|
|
--line: #343437;
|
|
--line-strong: #4a4658;
|
|
--text: #f0eadf;
|
|
--muted: #aaa5bd;
|
|
--accent: #8b5cf6;
|
|
--accent-strong: #a78bfa;
|
|
--accent-soft: rgba(139, 92, 246, .16);
|
|
--changed: #4d3b16;
|
|
--shadow: none;
|
|
}
|
|
|
|
body {
|
|
color: var(--text);
|
|
background: var(--bg);
|
|
}
|
|
|
|
.app-shell {
|
|
min-height: 100vh;
|
|
display: grid;
|
|
grid-template-columns: 220px minmax(0, 1fr);
|
|
grid-template-rows: 36px minmax(0, 1fr);
|
|
}
|
|
|
|
header {
|
|
grid-column: 2;
|
|
grid-row: 1;
|
|
position: static;
|
|
border-bottom: 1px solid var(--line);
|
|
background: #19191a;
|
|
backdrop-filter: none;
|
|
}
|
|
|
|
.topbar {
|
|
min-height: 36px;
|
|
padding: 0 18px;
|
|
justify-content: center;
|
|
}
|
|
|
|
.brand {
|
|
position: fixed;
|
|
left: 0;
|
|
top: 0;
|
|
width: 220px;
|
|
height: 36px;
|
|
padding: 0 12px;
|
|
gap: 8px;
|
|
border-right: 1px solid var(--line);
|
|
border-bottom: 1px solid var(--line);
|
|
background: #202021;
|
|
z-index: 20;
|
|
}
|
|
|
|
.mark {
|
|
width: auto;
|
|
height: auto;
|
|
border-radius: 0;
|
|
display: block;
|
|
background: transparent;
|
|
box-shadow: none;
|
|
color: var(--text);
|
|
font-size: 0;
|
|
}
|
|
|
|
.mark::after {
|
|
content: "exx";
|
|
font-size: 21px;
|
|
font-weight: 900;
|
|
color: #eadfce;
|
|
}
|
|
|
|
h1 {
|
|
font-size: 21px;
|
|
color: #eadfce;
|
|
}
|
|
|
|
.subtitle { display: none; }
|
|
|
|
.toolbar {
|
|
width: min(760px, 100%);
|
|
justify-content: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.select,
|
|
.input {
|
|
background: #252526;
|
|
border-color: var(--line);
|
|
color: var(--text);
|
|
}
|
|
|
|
.toolbar .select {
|
|
min-width: 220px;
|
|
}
|
|
|
|
.btn,
|
|
.icon-btn,
|
|
.select {
|
|
border-color: var(--line);
|
|
background: #252526;
|
|
color: var(--muted);
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.btn.primary {
|
|
border-color: var(--accent);
|
|
background: var(--accent);
|
|
color: #fff;
|
|
}
|
|
|
|
.btn.primary:hover { background: #7c3aed; }
|
|
.btn:hover, .icon-btn:hover, .select:hover { border-color: var(--accent); color: var(--text); }
|
|
|
|
main {
|
|
grid-column: 1 / -1;
|
|
grid-row: 2;
|
|
height: calc(100vh - 36px);
|
|
padding: 0;
|
|
display: grid;
|
|
grid-template-columns: 220px minmax(0, 1fr);
|
|
gap: 0;
|
|
background: var(--bg);
|
|
}
|
|
|
|
aside {
|
|
min-height: 0;
|
|
overflow: auto;
|
|
border-right: 1px solid var(--line);
|
|
background: #202021;
|
|
padding: 12px 8px;
|
|
}
|
|
|
|
.workspace {
|
|
min-height: 0;
|
|
display: grid;
|
|
grid-template-rows: auto minmax(0, 1fr);
|
|
gap: 0;
|
|
background: var(--bg);
|
|
}
|
|
|
|
.panel {
|
|
border-color: transparent;
|
|
border-radius: 0;
|
|
box-shadow: none;
|
|
background: transparent;
|
|
}
|
|
|
|
.drop-zone {
|
|
min-height: auto;
|
|
padding: 14px 10px;
|
|
border: 1px dashed var(--line-strong);
|
|
border-radius: 6px;
|
|
background: #252526;
|
|
text-align: left;
|
|
justify-items: start;
|
|
}
|
|
|
|
.drop-icon {
|
|
width: 30px;
|
|
height: 30px;
|
|
color: var(--accent-strong);
|
|
}
|
|
|
|
.drop-zone strong,
|
|
.meta-row b,
|
|
.sheet-btn,
|
|
.viewer-title strong,
|
|
.stat strong {
|
|
color: var(--text);
|
|
}
|
|
|
|
.drop-zone span,
|
|
.meta-row,
|
|
.viewer-title span,
|
|
.stat span,
|
|
.summary-line {
|
|
color: var(--muted);
|
|
}
|
|
|
|
.side-section {
|
|
margin-top: 16px;
|
|
padding: 0;
|
|
border-top: 1px solid var(--line);
|
|
padding-top: 14px;
|
|
}
|
|
|
|
.side-section h2,
|
|
.workspace h2 {
|
|
color: #8f89a7;
|
|
font-size: 11px;
|
|
letter-spacing: .08em;
|
|
}
|
|
|
|
.meta-list { gap: 11px; }
|
|
|
|
.meta-row b {
|
|
display: block;
|
|
grid-template-columns: none;
|
|
max-width: 120px;
|
|
}
|
|
|
|
.search-row {
|
|
grid-template-columns: 1fr 36px;
|
|
}
|
|
|
|
.sheet-btn {
|
|
border: 0;
|
|
border-left: 3px solid transparent;
|
|
background: transparent;
|
|
border-radius: 0;
|
|
color: var(--muted);
|
|
padding: 8px 8px;
|
|
}
|
|
|
|
.sheet-btn.active {
|
|
border-color: var(--accent);
|
|
background: #303031;
|
|
color: var(--text);
|
|
}
|
|
|
|
.summary-strip {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
|
gap: 0;
|
|
border-bottom: 1px solid var(--line);
|
|
background: #1c1c1d;
|
|
}
|
|
|
|
.stat {
|
|
min-height: 68px;
|
|
padding: 12px 16px;
|
|
border: 0;
|
|
border-right: 1px solid var(--line);
|
|
border-radius: 0;
|
|
background: transparent;
|
|
}
|
|
|
|
.stat strong { font-size: 22px; }
|
|
|
|
.viewer {
|
|
min-height: 0;
|
|
border: 0;
|
|
background: var(--bg);
|
|
}
|
|
|
|
.viewer-head {
|
|
min-height: 48px;
|
|
padding: 0 18px;
|
|
border-bottom: 1px solid var(--line);
|
|
background: #1c1c1d;
|
|
}
|
|
|
|
.view-tabs {
|
|
border-color: var(--line);
|
|
border-radius: 4px;
|
|
background: #202021;
|
|
}
|
|
|
|
.tab-btn {
|
|
min-height: 30px;
|
|
border-right-color: var(--line);
|
|
color: var(--muted);
|
|
background: transparent;
|
|
}
|
|
|
|
.tab-btn.active {
|
|
background: var(--accent);
|
|
color: white;
|
|
}
|
|
|
|
.viewer-body {
|
|
min-height: 0;
|
|
background: var(--bg);
|
|
}
|
|
|
|
.records-layout {
|
|
height: 100%;
|
|
min-height: 0;
|
|
grid-template-columns: 300px minmax(0, 1fr);
|
|
}
|
|
|
|
.record-list {
|
|
border-right: 1px solid var(--line);
|
|
background: #181819;
|
|
padding: 10px 8px;
|
|
}
|
|
|
|
.record-card {
|
|
width: 100%;
|
|
margin: 0 0 8px;
|
|
padding: 12px;
|
|
border: 1px solid transparent;
|
|
border-radius: 6px;
|
|
background: transparent;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.record-card.active {
|
|
border-color: var(--accent);
|
|
background: rgba(139, 92, 246, .10);
|
|
box-shadow: none;
|
|
}
|
|
|
|
.record-card:hover { background: #222224; }
|
|
|
|
.record-title {
|
|
color: #d9d4c8;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.record-card.active .record-title {
|
|
color: #f1eadf;
|
|
}
|
|
|
|
.record-meta {
|
|
color: #8f89a7;
|
|
font-size: 11px;
|
|
}
|
|
|
|
.record-detail {
|
|
background: var(--bg);
|
|
}
|
|
|
|
.record-article {
|
|
max-width: 980px;
|
|
margin: 0 auto;
|
|
padding: 44px clamp(34px, 6vw, 96px) 72px;
|
|
}
|
|
|
|
.article-kicker,
|
|
.field-name,
|
|
.article-fact b,
|
|
.profile-card h3 {
|
|
color: var(--accent-strong);
|
|
}
|
|
|
|
.record-article h2 {
|
|
color: #eadfce;
|
|
font-size: clamp(26px, 2.2vw, 40px);
|
|
line-height: 1.15;
|
|
}
|
|
|
|
.article-lead {
|
|
color: #d8d0c2;
|
|
border-left-color: #00a2a6;
|
|
}
|
|
|
|
.article-section {
|
|
border-top-color: var(--line);
|
|
}
|
|
|
|
.article-section h3 {
|
|
color: #eadfce;
|
|
}
|
|
|
|
.article-section p,
|
|
.article-fact span {
|
|
color: #e6dfd3;
|
|
}
|
|
|
|
.pill {
|
|
border: 1px solid rgba(139, 92, 246, .35);
|
|
background: rgba(139, 92, 246, .13);
|
|
color: var(--accent-strong);
|
|
border-radius: 3px;
|
|
}
|
|
|
|
.profile-card {
|
|
border-color: var(--line);
|
|
background: #202021;
|
|
border-radius: 6px;
|
|
}
|
|
|
|
|
|
.bar { background: #303033; }
|
|
.bar span { background: var(--accent); }
|
|
|
|
table, pre, .tree {
|
|
color: #e6dfd3;
|
|
background: var(--bg);
|
|
}
|
|
|
|
th {
|
|
background: #242426;
|
|
color: #d8d0c2;
|
|
}
|
|
|
|
td, th { border-color: var(--line); }
|
|
tbody tr:nth-child(even) td { background: #1c1c1d; }
|
|
tbody tr:hover td { background: rgba(139, 92, 246, .10); }
|
|
.row-index, th.row-index { background: #202021; color: var(--muted); }
|
|
|
|
mark {
|
|
background: rgba(139, 92, 246, .30);
|
|
color: #fff;
|
|
}
|
|
|
|
@media (max-width: 980px) {
|
|
.app-shell { grid-template-columns: 1fr; }
|
|
.brand { position: static; width: auto; border-right: 0; }
|
|
header { grid-column: 1; }
|
|
main { grid-template-columns: 1fr; height: auto; }
|
|
aside { border-right: 0; border-bottom: 1px solid var(--line); }
|
|
.records-layout { grid-template-columns: 1fr; }
|
|
.record-list { max-height: 320px; border-right: 0; border-bottom: 1px solid var(--line); }
|
|
}
|
|
.filter-select {
|
|
width: 100%;
|
|
margin-top: 10px;
|
|
margin-bottom: 8px;
|
|
}
|
|
/* Use more of the main context while keeping comfortable reader margins. */
|
|
.record-article {
|
|
width: min(1480px, calc(100% - clamp(56px, 8vw, 180px)));
|
|
max-width: 1480px;
|
|
margin: 0 auto;
|
|
padding: 44px 0 72px;
|
|
}
|
|
|
|
.record-article h2,
|
|
.article-kicker,
|
|
.article-meta,
|
|
.article-lead {
|
|
max-width: 1120px;
|
|
}
|
|
|
|
.article-section {
|
|
max-width: 1320px;
|
|
}
|
|
|
|
.article-section > p {
|
|
max-width: 980px;
|
|
}
|
|
|
|
.article-facts {
|
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
gap: 18px 28px;
|
|
max-width: 1320px;
|
|
}
|
|
|
|
.article-fact {
|
|
padding-left: 14px;
|
|
}
|
|
|
|
@media (max-width: 980px) {
|
|
.record-article {
|
|
width: calc(100% - 32px);
|
|
padding: 28px 0 48px;
|
|
}
|
|
}
|
|
/* Dense metadata layout: compact facts stay small, long text gets room. */
|
|
.article-section {
|
|
padding: 18px 0 22px;
|
|
}
|
|
|
|
.article-section h3 {
|
|
margin-bottom: 14px;
|
|
}
|
|
|
|
.article-facts {
|
|
display: grid;
|
|
grid-template-columns: repeat(12, minmax(0, 1fr));
|
|
gap: 14px 18px;
|
|
align-items: start;
|
|
max-width: 1320px;
|
|
}
|
|
|
|
.article-fact {
|
|
grid-column: span 3;
|
|
min-width: 0;
|
|
padding: 0 0 0 12px;
|
|
border-left: 2px solid rgba(170, 165, 189, .42);
|
|
}
|
|
|
|
.article-fact.compact {
|
|
grid-column: span 2;
|
|
}
|
|
|
|
.article-fact.medium {
|
|
grid-column: span 3;
|
|
}
|
|
|
|
.article-fact.wide {
|
|
grid-column: span 4;
|
|
}
|
|
|
|
.article-fact.long {
|
|
grid-column: span 6;
|
|
}
|
|
|
|
.article-fact.full {
|
|
grid-column: span 12;
|
|
max-width: 980px;
|
|
}
|
|
|
|
.article-fact b {
|
|
margin-bottom: 5px;
|
|
line-height: 1.2;
|
|
}
|
|
|
|
.article-fact span {
|
|
display: block;
|
|
font-size: 15px;
|
|
line-height: 1.46;
|
|
overflow-wrap: anywhere;
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
.article-fact.compact span,
|
|
.article-fact.medium span {
|
|
white-space: normal;
|
|
}
|
|
|
|
.article-section p {
|
|
max-width: 980px;
|
|
margin-bottom: 10px;
|
|
line-height: 1.58;
|
|
}
|
|
|
|
@media (max-width: 1300px) {
|
|
.article-fact.compact,
|
|
.article-fact.medium,
|
|
.article-fact.wide {
|
|
grid-column: span 4;
|
|
}
|
|
|
|
.article-fact.long {
|
|
grid-column: span 6;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 760px) {
|
|
.article-facts {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.article-fact,
|
|
.article-fact.compact,
|
|
.article-fact.medium,
|
|
.article-fact.wide,
|
|
.article-fact.long,
|
|
.article-fact.full {
|
|
grid-column: 1;
|
|
}
|
|
}
|
|
/* Exact reader article layout inspired by the reference screen. */
|
|
.record-article.reader-article {
|
|
width: min(1040px, calc(100% - 96px));
|
|
max-width: 1040px;
|
|
margin: 0 auto;
|
|
padding: 42px 0 96px;
|
|
color: #f0eadf;
|
|
}
|
|
|
|
.reader-nav {
|
|
display: grid;
|
|
grid-template-columns: 70px 1fr;
|
|
align-items: center;
|
|
margin-bottom: 50px;
|
|
color: #c9c2da;
|
|
font-size: 16px;
|
|
}
|
|
|
|
.reader-arrows {
|
|
display: flex;
|
|
gap: 16px;
|
|
color: #8f89a7;
|
|
line-height: 1;
|
|
}
|
|
|
|
.reader-arrow-btn {
|
|
width: 28px;
|
|
height: 28px;
|
|
border: 0;
|
|
background: transparent;
|
|
color: #8f89a7;
|
|
cursor: pointer;
|
|
display: inline-grid;
|
|
place-items: center;
|
|
font-size: 28px;
|
|
line-height: 1;
|
|
padding: 0;
|
|
}
|
|
|
|
.reader-arrow-btn:hover:not(:disabled) {
|
|
color: var(--accent-strong);
|
|
}
|
|
|
|
.reader-arrow-btn:disabled {
|
|
cursor: default;
|
|
opacity: .28;
|
|
}
|
|
|
|
.reader-crumbs {
|
|
justify-self: center;
|
|
color: #d8d0ee;
|
|
font-size: 16px;
|
|
line-height: 1.35;
|
|
text-align: center;
|
|
}
|
|
|
|
.reader-article h2 {
|
|
max-width: 960px;
|
|
margin: 0 0 18px;
|
|
color: #f1eadf;
|
|
font-size: 24px;
|
|
line-height: 1.17;
|
|
letter-spacing: 0;
|
|
text-transform: none;
|
|
}
|
|
|
|
.reader-meta-line {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 11px;
|
|
flex-wrap: wrap;
|
|
margin: 0 0 42px;
|
|
color: #d8d0ee;
|
|
font: 700 18px/1.25 "Cascadia Code", Consolas, monospace;
|
|
}
|
|
.reader-sticky-heading {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 30;
|
|
padding: 10px 0 28px;
|
|
border-bottom: 1px solid #39393b;
|
|
background: var(--bg);
|
|
}
|
|
|
|
.reader-sticky-heading .reader-meta-line {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.reader-sticky-heading + .reader-rule,
|
|
.reader-sticky-heading + .dashboard-view {
|
|
border-top: 0;
|
|
}
|
|
.reader-results-btn {
|
|
min-width: 136px;
|
|
min-height: 40px;
|
|
margin-left: 0;
|
|
align-self: center;
|
|
}
|
|
|
|
.reader-heading-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 14px;
|
|
margin-left: auto;
|
|
}
|
|
|
|
.reader-mitre-line {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 11px;
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.reader-mitre-badge {
|
|
max-width: 100%;
|
|
white-space: normal;
|
|
overflow-wrap: anywhere;
|
|
word-break: break-word;
|
|
}
|
|
|
|
.reader-severity {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
min-height: 28px;
|
|
padding: 0 10px;
|
|
border: 1px solid transparent;
|
|
background: #2a2a2b;
|
|
color: #f1eadf;
|
|
font-weight: 900;
|
|
letter-spacing: .02em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.reader-severity.severity-critical { background: #681923; border-color: #ef4444; color: #ffd9de; }
|
|
.reader-severity.severity-high { background: #5b2a16; border-color: #f97316; color: #ffe0c7; }
|
|
.reader-severity.severity-medium { background: #514117; border-color: #eab308; color: #fff1ad; }
|
|
.reader-severity.severity-low { background: #183f35; border-color: #22c55e; color: #d0faec; }
|
|
.reader-severity.severity-info { background: #173b5b; border-color: #3b82f6; color: #d9edff; }
|
|
|
|
.reader-dot { color: #827b93; }
|
|
|
|
.reader-label {
|
|
margin: 0 0 8px;
|
|
color: #d8d0ee;
|
|
font-size: 13px;
|
|
font-weight: 900;
|
|
letter-spacing: .06em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.reader-rule {
|
|
border-top: 1px solid #39393b;
|
|
padding-top: 24px;
|
|
margin-top: 0;
|
|
}
|
|
|
|
|
|
.reader-mode-btn {
|
|
justify-self: end;
|
|
min-height: 34px;
|
|
border: 1px solid #3d3d42;
|
|
border-radius: 5px;
|
|
background: #202021;
|
|
color: var(--accent-strong);
|
|
padding: 0 12px;
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
line-height: 1;
|
|
}
|
|
|
|
.reader-mode-btn:hover {
|
|
border-color: var(--accent-strong);
|
|
color: #f1eadf;
|
|
}
|
|
|
|
.reader-mode-btn:disabled {
|
|
border-color: #333338;
|
|
color: #716d79;
|
|
background: #1c1c1d;
|
|
cursor: not-allowed;
|
|
opacity: .65;
|
|
}
|
|
|
|
.reader-mode-btn:disabled:hover {
|
|
border-color: #333338;
|
|
color: #716d79;
|
|
}
|
|
.reader-nav-actions {
|
|
width: auto;
|
|
justify-self: end;
|
|
display: grid;
|
|
gap: 7px;
|
|
}
|
|
|
|
.reader-nav-actions .reader-mode-btn {
|
|
width: 100%;
|
|
}
|
|
|
|
.reader-bookmark-control {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 9px;
|
|
}
|
|
|
|
.reader-bookmark-label {
|
|
color: #d8d0ee;
|
|
font-size: 13px;
|
|
font-weight: 650;
|
|
}
|
|
|
|
.reader-bookmark-btn {
|
|
width: 36px;
|
|
height: 36px;
|
|
border: 1px solid #3d3d42;
|
|
border-radius: 5px;
|
|
background: #202021;
|
|
color: #8f89a7;
|
|
cursor: pointer;
|
|
display: inline-grid;
|
|
place-items: center;
|
|
padding: 0;
|
|
}
|
|
|
|
.reader-bookmark-btn:hover,
|
|
.reader-bookmark-btn[aria-pressed="true"] {
|
|
border-color: var(--accent-strong);
|
|
color: var(--accent-strong);
|
|
}
|
|
|
|
.reader-bookmark-btn svg {
|
|
width: 19px;
|
|
height: 19px;
|
|
}
|
|
.dashboard-view {
|
|
border-top: 1px solid #39393b;
|
|
padding-top: 24px;
|
|
}
|
|
|
|
.dashboard-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
gap: 14px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.edit-dashboard-view .dashboard-grid {
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
}
|
|
|
|
.dashboard-card {
|
|
border: 1px solid #3a3a3d;
|
|
border-radius: 7px;
|
|
background: #202021;
|
|
padding: 16px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.dashboard-card.wide { grid-column: span 2; }
|
|
.dashboard-card.full { grid-column: 1 / -1; }
|
|
|
|
.dashboard-value {
|
|
color: #f1eadf;
|
|
font-size: 19px;
|
|
line-height: 1.6;
|
|
margin: 8px 0 0;
|
|
white-space: pre-wrap;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.dashboard-kv {
|
|
display: grid;
|
|
grid-template-columns: minmax(150px, 24%) minmax(0, 1fr);
|
|
border-top: 1px solid #353538;
|
|
}
|
|
|
|
.dashboard-kv:first-of-type { border-top: 0; }
|
|
|
|
.dashboard-kv span,
|
|
.dashboard-value-cell {
|
|
padding: 10px 0;
|
|
font-size: 17px;
|
|
line-height: 1.55;
|
|
text-align: left;
|
|
}
|
|
|
|
.dashboard-kv span {
|
|
font-size: 13px;
|
|
color: #d8d0ee;
|
|
letter-spacing: .04em;
|
|
text-transform: uppercase;
|
|
overflow-wrap: break-word;
|
|
}
|
|
|
|
.dashboard-value-cell {
|
|
color: #f1eadf;
|
|
font-weight: 400;
|
|
min-width: 0;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
@media (max-width: 1100px) {
|
|
.dashboard-grid { grid-template-columns: 1fr; }
|
|
.dashboard-card.wide { grid-column: auto; }
|
|
}
|
|
|
|
.reader-summary-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 12px;
|
|
align-items: start;
|
|
}
|
|
|
|
.reader-summary-block {
|
|
min-width: 0;
|
|
width: 100%;
|
|
max-width: 100%;
|
|
box-sizing: border-box;
|
|
overflow: hidden;
|
|
margin: 0;
|
|
padding: 14px 16px;
|
|
border: 1px solid #38383b;
|
|
border-radius: 6px;
|
|
background: #181819;
|
|
}
|
|
|
|
.reader-summary-block.wide {
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
|
|
.reader-summary-field-heading {
|
|
display: block;
|
|
margin: 0 0 10px;
|
|
color: var(--accent-strong);
|
|
font-size: 16px;
|
|
font-weight: 900;
|
|
letter-spacing: .06em;
|
|
line-height: 1.2;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.reader-summary {
|
|
width: 100%;
|
|
max-width: 100%;
|
|
min-width: 0;
|
|
margin: 0;
|
|
color: #f1eadf;
|
|
font-size: 20px;
|
|
font-weight: 400;
|
|
line-height: 1.66;
|
|
white-space: pre-wrap;
|
|
overflow-wrap: anywhere;
|
|
word-break: break-word;
|
|
hyphens: auto;
|
|
}
|
|
|
|
.reader-summary-list {
|
|
max-width: 940px;
|
|
margin: 12px 0 0;
|
|
padding-left: 22px;
|
|
color: #f1eadf;
|
|
font-size: 18px;
|
|
font-weight: 400;
|
|
line-height: 1.72;
|
|
}
|
|
|
|
.reader-summary-list li {
|
|
margin-bottom: 10px;
|
|
padding-left: 8px;
|
|
}
|
|
|
|
.reader-summary-list li::marker { color: var(--accent-strong); }
|
|
|
|
.reader-topic-box {
|
|
margin: 44px 0 36px;
|
|
border: 1px solid #3a3a3d;
|
|
border-radius: 8px;
|
|
background: #202021;
|
|
min-height: 96px;
|
|
padding: 18px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 20px;
|
|
}
|
|
|
|
.reader-topic-box .reader-label {
|
|
color: var(--accent-strong);
|
|
font-size: 12px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.reader-tags {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
}
|
|
|
|
.reader-tag {
|
|
border: 1px solid #3d3d42;
|
|
background: #252526;
|
|
color: var(--accent-strong);
|
|
min-height: 30px;
|
|
padding: 5px 10px;
|
|
font-size: 14px;
|
|
line-height: 1.2;
|
|
}
|
|
|
|
.reader-section {
|
|
margin-top: 34px;
|
|
}
|
|
|
|
.reader-section > .reader-label {
|
|
border-bottom: 1px solid #39393b;
|
|
padding-bottom: 9px;
|
|
margin-bottom: 26px;
|
|
}
|
|
|
|
.reader-card-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 18px;
|
|
}
|
|
|
|
.reader-card {
|
|
min-height: 112px;
|
|
border: 1px solid #3a3a3d;
|
|
border-radius: 7px;
|
|
background: #202021;
|
|
padding: 20px 20px 18px;
|
|
}
|
|
|
|
.reader-card .reader-label {
|
|
color: var(--accent-strong);
|
|
font-size: 12px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.reader-card p {
|
|
margin: 0;
|
|
color: #f1eadf;
|
|
font-size: 16px;
|
|
font-weight: 400;
|
|
line-height: 1.55;
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
.reader-metadata {
|
|
margin-top: 36px;
|
|
border: 1px solid #3a3a3d;
|
|
border-radius: 7px;
|
|
background: #202021;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.reader-metadata .reader-label {
|
|
color: var(--accent-strong);
|
|
padding: 18px 18px 12px;
|
|
margin: 0;
|
|
}
|
|
|
|
.reader-meta-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font: 700 15px/1.45 "Cascadia Code", Consolas, monospace;
|
|
}
|
|
|
|
.reader-meta-table th,
|
|
.reader-meta-table td {
|
|
border-top: 1px solid #353538;
|
|
border-right: 0;
|
|
border-bottom: 0;
|
|
background: transparent;
|
|
padding: 9px 18px;
|
|
text-align: left;
|
|
vertical-align: top;
|
|
}
|
|
|
|
.reader-meta-table th {
|
|
width: 120px;
|
|
color: #d8d0ee;
|
|
position: static;
|
|
}
|
|
|
|
.reader-meta-table td {
|
|
color: #f1eadf;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.reader-meta-table a,
|
|
.reader-linkish {
|
|
color: var(--accent-strong);
|
|
text-decoration: none;
|
|
}
|
|
|
|
|
|
.reader-all-fields {
|
|
margin-top: 28px;
|
|
}
|
|
|
|
.reader-all-fields summary {
|
|
list-style: none;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.reader-all-fields summary::-webkit-details-marker {
|
|
display: none;
|
|
}
|
|
|
|
.reader-all-fields summary.reader-label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 18px;
|
|
}
|
|
|
|
.reader-all-fields summary.reader-label::before {
|
|
content: '+';
|
|
color: var(--accent-strong);
|
|
font-size: 20px;
|
|
line-height: 1;
|
|
transition: transform .15s ease;
|
|
}
|
|
|
|
.reader-all-fields[open] summary.reader-label::before {
|
|
transform: rotate(90deg);
|
|
}
|
|
|
|
.reader-all-fields .reader-meta-table {
|
|
table-layout: fixed;
|
|
}
|
|
|
|
.reader-all-fields .reader-meta-table th {
|
|
font-size: 13px;
|
|
line-height: 1.35;
|
|
width: clamp(180px, 24%, 280px);
|
|
white-space: normal;
|
|
overflow-wrap: break-word;
|
|
word-break: normal;
|
|
hyphens: auto;
|
|
}
|
|
|
|
.reader-all-fields .reader-meta-table td {
|
|
font-size: 17px;
|
|
line-height: 1.6;
|
|
white-space: normal;
|
|
overflow-wrap: anywhere;
|
|
word-break: normal;
|
|
}
|
|
|
|
.linked-results-body {
|
|
border-top: 1px solid #353538;
|
|
padding: 0 18px 18px;
|
|
}
|
|
|
|
.linked-results-source {
|
|
color: #d8d0ee;
|
|
font-family: inherit;
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
line-height: 1.45;
|
|
margin: 18px 0 8px;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.linked-results-scroll {
|
|
max-height: min(520px, 58vh);
|
|
overflow: auto;
|
|
border: 1px solid #353538;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.linked-results-table {
|
|
min-width: max-content;
|
|
font-family: inherit;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
line-height: 1.45;
|
|
}
|
|
|
|
.linked-results-table th,
|
|
.linked-results-table td {
|
|
min-width: 150px;
|
|
max-width: 420px;
|
|
padding: 9px 12px;
|
|
white-space: normal;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.linked-results-table th {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 1;
|
|
background: #202021;
|
|
color: #d8d0ee;
|
|
}
|
|
|
|
/* Unified UI typography based on the reader context panels. */
|
|
:root {
|
|
--ui-label-size: 13px;
|
|
--ui-small-size: 13px;
|
|
--ui-control-size: 14px;
|
|
--ui-body-size: 15px;
|
|
--ui-value-size: 15px;
|
|
--ui-label-weight: 900;
|
|
--ui-strong-weight: 800;
|
|
--ui-body-weight: 650;
|
|
--ui-label-tracking: .06em;
|
|
}
|
|
|
|
body {
|
|
font-size: var(--ui-body-size);
|
|
line-height: 1.45;
|
|
font-weight: var(--ui-body-weight);
|
|
}
|
|
|
|
.side-section h2,
|
|
.workspace h2,
|
|
.stat span,
|
|
.reader-label,
|
|
.field-name,
|
|
.article-fact b,
|
|
.profile-meta span,
|
|
th,
|
|
.article-kicker {
|
|
color: #d8d0ee;
|
|
font-size: var(--ui-label-size);
|
|
font-weight: var(--ui-label-weight);
|
|
letter-spacing: var(--ui-label-tracking);
|
|
line-height: 1.2;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.reader-card .reader-label,
|
|
.reader-topic-box .reader-label,
|
|
.reader-metadata .reader-label,
|
|
.profile-card h3,
|
|
.record-card.active .record-title {
|
|
color: var(--accent-strong);
|
|
}
|
|
|
|
.btn,
|
|
.icon-btn,
|
|
.select,
|
|
.input,
|
|
.tab-btn,
|
|
.sheet-btn,
|
|
.meta-row,
|
|
.viewer-title span,
|
|
.record-meta,
|
|
.pill,
|
|
.reader-tag,
|
|
td,
|
|
pre,
|
|
.tree,
|
|
.notice {
|
|
font-size: var(--ui-control-size);
|
|
line-height: 1.35;
|
|
}
|
|
|
|
.btn,
|
|
.select,
|
|
.tab-btn,
|
|
.sheet-btn,
|
|
.record-card,
|
|
.meta-row b,
|
|
.viewer-title strong,
|
|
.stat strong,
|
|
.profile-meta b,
|
|
|
|
.btn,
|
|
.icon-btn,
|
|
.select,
|
|
.input {
|
|
min-height: 40px;
|
|
border-radius: 5px;
|
|
}
|
|
|
|
.btn {
|
|
padding: 0 16px;
|
|
}
|
|
|
|
.icon-btn {
|
|
width: 40px;
|
|
}
|
|
|
|
.tab-btn {
|
|
min-height: 36px;
|
|
padding: 0 14px;
|
|
}
|
|
|
|
.side-section h2,
|
|
.workspace h2 {
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.meta-list {
|
|
gap: 12px;
|
|
}
|
|
|
|
.meta-row {
|
|
align-items: baseline;
|
|
}
|
|
|
|
.meta-row b {
|
|
font-size: var(--ui-value-size);
|
|
color: #f1eadf;
|
|
}
|
|
|
|
.sheet-btn {
|
|
min-height: 40px;
|
|
padding: 9px 10px;
|
|
align-items: center;
|
|
}
|
|
|
|
.sheet-btn small {
|
|
color: inherit;
|
|
font-size: var(--ui-small-size);
|
|
font-weight: var(--ui-strong-weight);
|
|
}
|
|
|
|
.summary-strip {
|
|
grid-template-columns: repeat(4, minmax(150px, 1fr));
|
|
}
|
|
|
|
.stat {
|
|
min-height: 72px;
|
|
padding: 13px 18px;
|
|
}
|
|
|
|
.stat span {
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.stat strong {
|
|
color: #f1eadf;
|
|
font-size: 24px;
|
|
line-height: 1;
|
|
}
|
|
|
|
.viewer-head {
|
|
min-height: 58px;
|
|
padding: 0 18px;
|
|
}
|
|
|
|
.viewer-title strong {
|
|
color: #f1eadf;
|
|
font-size: var(--ui-value-size);
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.viewer-title span {
|
|
color: #aaa5bd;
|
|
font-size: var(--ui-small-size);
|
|
font-weight: var(--ui-body-weight);
|
|
}
|
|
|
|
.record-card {
|
|
padding: 14px 14px;
|
|
gap: 8px;
|
|
}
|
|
|
|
.record-title {
|
|
color: #f1eadf;
|
|
font-size: 18px;
|
|
font-weight: 760;
|
|
line-height: 1.55;
|
|
letter-spacing: 0;
|
|
}
|
|
|
|
.record-meta {
|
|
color: #aaa5bd;
|
|
font-size: var(--ui-small-size);
|
|
font-weight: var(--ui-body-weight);
|
|
}
|
|
|
|
.reader-meta-line,
|
|
.reader-meta-table,
|
|
.reader-card p,
|
|
.article-fact span,
|
|
.article-section p,
|
|
.profile-card,
|
|
table {
|
|
font-size: var(--ui-value-size);
|
|
}
|
|
|
|
.profile-card {
|
|
padding: 16px;
|
|
gap: 12px;
|
|
}
|
|
|
|
.profile-card h3 {
|
|
font-size: 16px;
|
|
font-weight: var(--ui-strong-weight);
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.action-panel {
|
|
display: grid;
|
|
gap: 10px;
|
|
}
|
|
|
|
.action-panel-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
color: var(--muted);
|
|
font-size: var(--ui-control-size);
|
|
}
|
|
|
|
.action-toggle {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
color: var(--text);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.action-toggle input,
|
|
.action-check input {
|
|
accent-color: var(--accent-strong);
|
|
}
|
|
|
|
.action-export-row {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr 1fr;
|
|
gap: 6px;
|
|
}
|
|
|
|
.review-panel {
|
|
scroll-margin-top: 20px;
|
|
margin: 28px 0 0;
|
|
border: 1px solid #3a3a3d;
|
|
border-radius: 7px;
|
|
background: #202021;
|
|
padding: 0;
|
|
}
|
|
|
|
.review-panel summary {
|
|
list-style: none;
|
|
}
|
|
|
|
.review-panel summary::-webkit-details-marker {
|
|
display: none;
|
|
}
|
|
|
|
.review-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
min-height: 52px;
|
|
padding: 0 16px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.review-head::before {
|
|
content: '+';
|
|
color: var(--accent-strong);
|
|
font-size: 22px;
|
|
line-height: 1;
|
|
transition: transform .15s ease;
|
|
}
|
|
|
|
.review-panel[open] .review-head::before {
|
|
transform: rotate(90deg);
|
|
}
|
|
|
|
.review-head .reader-label {
|
|
margin: 0 auto 0 0;
|
|
}
|
|
|
|
.review-body {
|
|
border-top: 1px solid #3a3a3d;
|
|
padding: 16px;
|
|
}
|
|
|
|
.action-check {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
color: #f1eadf;
|
|
font-size: var(--ui-control-size);
|
|
}
|
|
|
|
.review-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 12px;
|
|
}
|
|
|
|
.review-field {
|
|
display: grid;
|
|
gap: 6px;
|
|
}
|
|
|
|
.review-field.full {
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.review-field label,
|
|
.review-marker-label {
|
|
color: var(--accent-strong);
|
|
font-size: 12px;
|
|
letter-spacing: .06em;
|
|
line-height: 1.2;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.review-input,
|
|
.review-select,
|
|
.review-textarea {
|
|
width: 100%;
|
|
border: 1px solid #3d3d42;
|
|
border-radius: 5px;
|
|
background: #151516;
|
|
color: #f1eadf;
|
|
font: 400 14px/1.35 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
padding: 9px 10px;
|
|
}
|
|
|
|
.review-textarea {
|
|
min-height: 74px;
|
|
resize: vertical;
|
|
}
|
|
.markdown-preview {
|
|
border: 1px solid #3d3d42;
|
|
border-radius: 5px;
|
|
background: #101011;
|
|
color: #f1eadf;
|
|
padding: 10px;
|
|
}
|
|
|
|
.markdown-preview:empty {
|
|
display: none;
|
|
}
|
|
|
|
.markdown-content {
|
|
font-size: 14px;
|
|
line-height: 1.5;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.markdown-content p,
|
|
.markdown-content ul,
|
|
.markdown-content ol,
|
|
.markdown-content table {
|
|
margin: 0 0 10px;
|
|
}
|
|
|
|
.markdown-content > :last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.markdown-content ul,
|
|
.markdown-content ol {
|
|
padding-left: 22px;
|
|
}
|
|
|
|
.markdown-content li + li {
|
|
margin-top: 3px;
|
|
}
|
|
|
|
.markdown-content table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
table-layout: auto;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.markdown-content th,
|
|
.markdown-content td {
|
|
min-width: 120px;
|
|
max-width: none;
|
|
border: 1px solid #3d3d42;
|
|
padding: 7px 8px;
|
|
vertical-align: top;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.markdown-content th {
|
|
position: static;
|
|
background: #252526;
|
|
color: #d8d0ee;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.markdown-content td {
|
|
background: #151516;
|
|
color: #f1eadf;
|
|
}
|
|
|
|
.markdown-content code {
|
|
border: 1px solid #3d3d42;
|
|
border-radius: 4px;
|
|
background: #202021;
|
|
color: #f1eadf;
|
|
padding: 1px 4px;
|
|
font-family: "Cascadia Code", Consolas, monospace;
|
|
font-size: .92em;
|
|
}
|
|
|
|
.review-markers {
|
|
grid-column: 1 / -1;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
align-items: center;
|
|
}
|
|
|
|
.review-marker-label {
|
|
flex-basis: 100%;
|
|
}
|
|
|
|
.review-marker {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
border: 1px solid #3d3d42;
|
|
border-radius: 5px;
|
|
padding: 7px 9px;
|
|
color: #d8d0ee;
|
|
background: #252526;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.action-pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
width: fit-content;
|
|
border: 1px solid var(--accent-strong);
|
|
border-radius: 999px;
|
|
color: var(--accent-strong);
|
|
padding: 2px 8px;
|
|
font-size: 12px;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
/* Normalize visible record list typography. */
|
|
.drop-zone strong,
|
|
.meta-row b,
|
|
.sheet-btn,
|
|
.sheet-btn.active,
|
|
.sheet-btn small,
|
|
.viewer-title strong,
|
|
.stat strong,
|
|
.record-title,
|
|
.record-card.active .record-title {
|
|
font-weight: 400;
|
|
}
|
|
|
|
.reader-article h2,
|
|
.reader-article .reader-meta-line,
|
|
.reader-article .reader-severity,
|
|
.reader-article .reader-label,
|
|
.reader-article .reader-summary-field-heading,
|
|
.reader-article .reader-summary,
|
|
.reader-article .reader-summary-list,
|
|
.reader-article .reader-tag,
|
|
.reader-article .reader-card p,
|
|
.reader-article .reader-meta-table,
|
|
.reader-article .reader-meta-table th,
|
|
.reader-article .reader-meta-table td {
|
|
font-weight: 400;
|
|
}
|
|
|
|
.reader-article mark {
|
|
font-weight: 400;
|
|
}
|
|
|
|
@media (max-width: 980px) {
|
|
.record-article.reader-article {
|
|
width: calc(100% - 32px);
|
|
padding-top: 28px;
|
|
}
|
|
|
|
.reader-nav {
|
|
grid-template-columns: 1fr;
|
|
gap: 12px;
|
|
margin-bottom: 28px;
|
|
}
|
|
|
|
.reader-crumbs { justify-self: start; text-align: left; }
|
|
.reader-heading-actions { width: 100%; margin-left: 0; justify-content: flex-end; }
|
|
.reader-summary-grid { grid-template-columns: 1fr; }
|
|
.reader-summary-block.wide { grid-column: auto; }
|
|
.reader-topic-box { align-items: stretch; flex-direction: column; }
|
|
.reader-card-grid { grid-template-columns: 1fr; }
|
|
}
|
|
|
|
main {
|
|
position: relative;
|
|
transition: grid-template-columns .18s ease;
|
|
}
|
|
|
|
.sidebar-toggle {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 220px;
|
|
z-index: 8;
|
|
width: 24px;
|
|
height: 68px;
|
|
border: 1px solid var(--accent);
|
|
border-left: 0;
|
|
border-radius: 0 8px 8px 0;
|
|
background: #202021;
|
|
color: var(--accent-strong);
|
|
cursor: pointer;
|
|
display: grid;
|
|
place-items: center;
|
|
transform: translateY(-50%);
|
|
transition: left .18s ease;
|
|
}
|
|
|
|
.sidebar-toggle::before {
|
|
content: '<';
|
|
font-size: 18px;
|
|
line-height: 1;
|
|
}
|
|
|
|
body.sidebar-collapsed main {
|
|
grid-template-columns: 0 minmax(0, 1fr);
|
|
}
|
|
|
|
body.sidebar-collapsed aside {
|
|
width: 0;
|
|
min-width: 0;
|
|
padding-left: 0;
|
|
padding-right: 0;
|
|
border-right: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
body.sidebar-collapsed .sidebar-toggle {
|
|
left: 0;
|
|
border-left: 1px solid var(--accent);
|
|
border-radius: 0 8px 8px 0;
|
|
}
|
|
|
|
body.sidebar-collapsed .sidebar-toggle::before {
|
|
content: '>';
|
|
}
|
|
|
|
.viewer {
|
|
grid-template-rows: auto auto minmax(0, 1fr);
|
|
}
|
|
|
|
.viewer-workbar {
|
|
display: grid;
|
|
grid-template-columns: minmax(280px, 1.2fr) minmax(220px, .8fr) auto;
|
|
gap: 10px;
|
|
align-items: center;
|
|
padding: 10px 12px;
|
|
border-bottom: 1px solid var(--line);
|
|
background: #181819;
|
|
}
|
|
|
|
.workbar-group {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.workbar-group.search-tools {
|
|
display: grid;
|
|
grid-template-columns: minmax(150px, 220px) minmax(180px, 1fr) 40px;
|
|
gap: 8px;
|
|
}
|
|
|
|
.safe-value-link {
|
|
color: var(--accent-strong);
|
|
text-decoration: underline;
|
|
text-underline-offset: 2px;
|
|
}
|
|
|
|
.safe-value-link:hover {
|
|
color: #c5a7ff;
|
|
}
|
|
.search-result-nav {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, 30px);
|
|
gap: 4px;
|
|
}
|
|
|
|
.search-result-nav .icon-btn {
|
|
width: 30px;
|
|
min-height: 30px;
|
|
padding: 0;
|
|
font-size: 16px;
|
|
}
|
|
.results-floating-window {
|
|
position: fixed;
|
|
z-index: 2000;
|
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
top: 10vh;
|
|
left: 18vw;
|
|
width: min(1040px, 78vw);
|
|
height: min(680px, 76vh);
|
|
min-width: 440px;
|
|
min-height: 280px;
|
|
max-width: calc(100vw - 16px);
|
|
max-height: calc(100vh - 16px);
|
|
display: grid;
|
|
grid-template-rows: auto minmax(0, 1fr);
|
|
resize: both;
|
|
overflow: hidden;
|
|
border: 1px solid #5b4b7f;
|
|
border-radius: 8px;
|
|
background: #181819;
|
|
box-shadow: 0 22px 70px rgba(0, 0, 0, .72);
|
|
}
|
|
|
|
.results-floating-window button,
|
|
.results-floating-window table,
|
|
.results-floating-window th,
|
|
.results-floating-window td {
|
|
font-family: inherit;
|
|
}
|
|
.results-floating-window[hidden] { display: none; }
|
|
|
|
.results-floating-head {
|
|
grid-column: 1;
|
|
grid-row: 1;
|
|
position: static;
|
|
top: auto;
|
|
z-index: auto;
|
|
min-height: 48px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
padding: 8px 10px 8px 16px;
|
|
border-bottom: 1px solid #3a3a3d;
|
|
background: #242225;
|
|
cursor: move;
|
|
user-select: none;
|
|
touch-action: none;
|
|
}
|
|
|
|
.results-floating-title {
|
|
min-width: 0;
|
|
color: #f1eadf;
|
|
font-weight: 700;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.results-floating-close {
|
|
width: 34px;
|
|
height: 32px;
|
|
flex: 0 0 auto;
|
|
border: 1px solid #464149;
|
|
border-radius: 5px;
|
|
background: #202021;
|
|
color: #f1eadf;
|
|
cursor: pointer;
|
|
font-size: 20px;
|
|
line-height: 1;
|
|
}
|
|
|
|
.results-floating-close:hover { border-color: var(--accent-strong); }
|
|
|
|
.results-floating-body {
|
|
grid-column: 1;
|
|
grid-row: 2;
|
|
min-height: 0;
|
|
overflow: auto;
|
|
padding: 0 18px 18px;
|
|
}
|
|
|
|
.linked-results-toolbar {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
padding-top: 14px;
|
|
}
|
|
|
|
.linked-results-toolbar .reader-mode-btn { width: auto; }
|
|
|
|
.linked-results-empty {
|
|
margin-top: 14px;
|
|
padding: 14px;
|
|
border-left: 3px solid #d9a441;
|
|
background: rgba(217, 164, 65, .10);
|
|
color: #f1dfb2;
|
|
line-height: 1.55;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
/* Match the Results popup typography to the app's normal reading text. */
|
|
.results-floating-window {
|
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
font-size: 20px;
|
|
font-weight: 400;
|
|
line-height: 1.55;
|
|
}
|
|
|
|
.results-floating-window .results-floating-title {
|
|
font-size: 20px;
|
|
font-weight: 650;
|
|
line-height: 1.35;
|
|
}
|
|
|
|
.results-floating-window .results-floating-body {
|
|
font-size: 20px;
|
|
font-weight: 400;
|
|
line-height: 1.55;
|
|
}
|
|
|
|
.results-floating-window .linked-results-source {
|
|
font-size: 20px;
|
|
font-weight: 650;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.results-floating-window .linked-results-table,
|
|
.results-floating-window .linked-results-table th,
|
|
.results-floating-window .linked-results-table td {
|
|
font-family: inherit;
|
|
font-size: 20px;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.results-floating-window .linked-results-table th {
|
|
font-weight: 650;
|
|
}
|
|
|
|
.results-floating-window .linked-results-table td {
|
|
font-weight: 400;
|
|
}
|
|
|
|
.results-floating-window .linked-results-empty {
|
|
font-size: 20px;
|
|
line-height: 1.55;
|
|
}
|
|
@media (max-width: 720px) {
|
|
.results-floating-window {
|
|
left: 8px;
|
|
top: 8px;
|
|
width: calc(100vw - 16px);
|
|
height: min(78vh, 680px);
|
|
min-width: 0;
|
|
}
|
|
}
|
|
|
|
.project-import-dialog {
|
|
width: min(760px, calc(100vw - 40px));
|
|
max-height: min(760px, calc(100vh - 40px));
|
|
padding: 0;
|
|
border: 1px solid #4a4a50;
|
|
border-radius: 8px;
|
|
background: #202021;
|
|
color: #f1eadf;
|
|
box-shadow: 0 24px 80px rgba(0, 0, 0, .55);
|
|
}
|
|
|
|
.project-import-dialog::backdrop { background: rgba(0, 0, 0, .72); }
|
|
.project-import-head,
|
|
.project-import-actions {
|
|
padding: 18px 20px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
border-bottom: 1px solid #3a3a3d;
|
|
}
|
|
.project-import-head h2 { margin: 0; font-size: 20px; }
|
|
.project-import-body { padding: 20px; overflow: auto; }
|
|
.project-import-actions { border-top: 1px solid #3a3a3d; border-bottom: 0; justify-content: flex-end; }
|
|
.project-import-summary { margin: 0 0 16px; color: #d8d0ee; line-height: 1.5; }
|
|
.project-import-table { width: 100%; }
|
|
.project-import-table th { position: static; }
|
|
.project-import-warning { margin-top: 14px; padding: 10px 12px; border-left: 3px solid #d9a441; background: rgba(217, 164, 65, .10); color: #f1dfb2; }
|
|
.workbar-group.sheet-tools .sheet-list {
|
|
width: 100%;
|
|
display: flex;
|
|
gap: 6px;
|
|
overflow-x: auto;
|
|
scrollbar-width: thin;
|
|
}
|
|
|
|
.workbar-group.sheet-tools .sheet-btn {
|
|
width: auto;
|
|
min-width: 150px;
|
|
margin: 0;
|
|
white-space: nowrap;
|
|
flex: 0 0 auto;
|
|
}
|
|
|
|
.workbar-action {
|
|
justify-content: flex-end;
|
|
color: #d8d0ee;
|
|
}
|
|
|
|
.workbar-action .action-panel-row {
|
|
min-width: 94px;
|
|
justify-content: flex-start;
|
|
gap: 8px;
|
|
}
|
|
|
|
.workbar-action .action-toggle {
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.viewer-workbar .filter-select,
|
|
.viewer-workbar .input {
|
|
min-height: 36px;
|
|
background: #202021;
|
|
color: #f1eadf;
|
|
border-color: #3d3d42;
|
|
margin: 0;
|
|
}
|
|
|
|
.viewer-workbar .icon-btn {
|
|
background: #202021;
|
|
color: #d8d0ee;
|
|
border-color: #3d3d42;
|
|
}
|
|
|
|
@media (max-width: 1180px) {
|
|
.viewer-workbar { grid-template-columns: 1fr; }
|
|
.workbar-action { justify-content: flex-start; flex-wrap: wrap; }
|
|
}
|
|
|
|
/* Compact two-row main header */
|
|
.workspace {
|
|
grid-template-rows: auto minmax(0, 1fr);
|
|
min-height: 0;
|
|
gap: 0;
|
|
}
|
|
|
|
.summary-strip {
|
|
min-height: 54px;
|
|
border-bottom: 1px solid var(--line);
|
|
}
|
|
|
|
.stat {
|
|
min-height: 54px;
|
|
padding: 8px 18px;
|
|
}
|
|
|
|
.stat span {
|
|
margin-bottom: 4px;
|
|
font-size: 12px;
|
|
line-height: 1.1;
|
|
}
|
|
|
|
.stat strong {
|
|
font-size: 20px;
|
|
line-height: 1.05;
|
|
}
|
|
|
|
.viewer {
|
|
grid-template-rows: auto minmax(0, 1fr);
|
|
}
|
|
|
|
.viewer-head {
|
|
min-height: 0;
|
|
display: grid;
|
|
grid-template-columns: minmax(260px, 1fr) auto;
|
|
grid-template-areas:
|
|
"title actions"
|
|
"workbar workbar";
|
|
gap: 8px 12px;
|
|
align-items: center;
|
|
padding: 8px 12px;
|
|
background: #181819;
|
|
}
|
|
|
|
.viewer-title {
|
|
grid-area: title;
|
|
}
|
|
|
|
.viewer-title strong {
|
|
font-size: 14px;
|
|
line-height: 1.2;
|
|
}
|
|
|
|
.viewer-title span {
|
|
font-size: 12px;
|
|
line-height: 1.2;
|
|
}
|
|
|
|
.viewer-actions {
|
|
grid-area: actions;
|
|
}
|
|
|
|
.viewer-workbar {
|
|
grid-area: workbar;
|
|
grid-template-columns: minmax(280px, 1fr) minmax(260px, .75fr) auto;
|
|
gap: 8px;
|
|
padding: 0;
|
|
border-bottom: 0;
|
|
background: transparent;
|
|
}
|
|
|
|
.workbar-group.search-tools {
|
|
grid-template-columns: minmax(150px, 220px) minmax(180px, 1fr) 38px;
|
|
}
|
|
|
|
.viewer-workbar .filter-select,
|
|
.viewer-workbar .input,
|
|
.viewer-workbar .icon-btn,
|
|
.workbar-group.sheet-tools .sheet-btn,
|
|
.header-export-row .btn,
|
|
.tab-btn {
|
|
min-height: 32px;
|
|
}
|
|
|
|
.workbar-group.sheet-tools .sheet-list {
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
.workbar-group.sheet-tools .sheet-btn {
|
|
min-width: 138px;
|
|
padding: 6px 9px;
|
|
}
|
|
|
|
.workbar-action {
|
|
gap: 12px;
|
|
}
|
|
|
|
.workbar-action .action-panel-row,
|
|
.workbar-action .action-toggle {
|
|
font-size: 13px;
|
|
}
|
|
|
|
.viewer-body {
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.records-layout {
|
|
min-height: 0;
|
|
height: 100%;
|
|
}
|
|
|
|
.record-list,
|
|
.record-detail {
|
|
min-height: 0;
|
|
overflow: auto;
|
|
scrollbar-width: thin;
|
|
scrollbar-color: #777 #202021;
|
|
}
|
|
|
|
.record-list::-webkit-scrollbar,
|
|
.record-detail::-webkit-scrollbar,
|
|
.viewer-body::-webkit-scrollbar {
|
|
width: 10px;
|
|
height: 10px;
|
|
}
|
|
|
|
.record-list::-webkit-scrollbar-track,
|
|
.record-detail::-webkit-scrollbar-track,
|
|
.viewer-body::-webkit-scrollbar-track {
|
|
background: #202021;
|
|
}
|
|
|
|
.record-list::-webkit-scrollbar-thumb,
|
|
.record-detail::-webkit-scrollbar-thumb,
|
|
.viewer-body::-webkit-scrollbar-thumb {
|
|
background: #777;
|
|
border-radius: 999px;
|
|
border: 2px solid #202021;
|
|
}
|
|
|
|
@media (max-width: 1180px) {
|
|
.viewer-head {
|
|
grid-template-columns: 1fr;
|
|
grid-template-areas:
|
|
"title"
|
|
"actions"
|
|
"workbar";
|
|
}
|
|
.viewer-actions { justify-content: flex-start; }
|
|
.viewer-workbar { grid-template-columns: 1fr; }
|
|
}
|
|
|
|
/* Dense two-line command bar */
|
|
.workspace {
|
|
grid-template-rows: auto minmax(0, 1fr);
|
|
gap: 0;
|
|
min-height: 0;
|
|
}
|
|
|
|
.summary-strip {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(84px, 124px)) minmax(0, 1fr);
|
|
gap: 0;
|
|
min-height: 96px;
|
|
border-bottom: 1px solid var(--line);
|
|
background: #181819;
|
|
}
|
|
|
|
.stat {
|
|
min-height: 0;
|
|
border-width: 0 1px 0 0;
|
|
border-radius: 0;
|
|
padding: 8px 12px;
|
|
background: #1c1c1d;
|
|
}
|
|
|
|
.stat span {
|
|
margin-bottom: 4px;
|
|
font-size: 11px;
|
|
line-height: 1;
|
|
}
|
|
|
|
.stat strong {
|
|
font-size: 18px;
|
|
line-height: 1.05;
|
|
}
|
|
|
|
.summary-controls {
|
|
min-width: 0;
|
|
display: grid;
|
|
grid-template-rows: minmax(34px, auto) minmax(34px, auto);
|
|
gap: 8px;
|
|
padding: 8px 12px;
|
|
align-content: center;
|
|
}
|
|
|
|
.summary-mainline {
|
|
min-width: 0;
|
|
display: grid;
|
|
grid-template-columns: minmax(260px, 1fr) max-content;
|
|
gap: 16px;
|
|
align-items: center;
|
|
}
|
|
|
|
.summary-controls .viewer-title,
|
|
.summary-controls .viewer-actions,
|
|
.summary-controls .viewer-workbar {
|
|
grid-area: auto;
|
|
}
|
|
|
|
.summary-mainline .viewer-actions {
|
|
display: grid;
|
|
grid-template-columns: max-content max-content;
|
|
gap: 10px;
|
|
align-items: center;
|
|
justify-content: end;
|
|
min-width: 0;
|
|
}
|
|
|
|
.summary-controls .viewer-title strong {
|
|
font-size: 13px;
|
|
line-height: 1.1;
|
|
}
|
|
|
|
.summary-controls .viewer-title span {
|
|
font-size: 11px;
|
|
line-height: 1.1;
|
|
}
|
|
|
|
.viewer {
|
|
grid-template-rows: minmax(0, 1fr);
|
|
}
|
|
|
|
.viewer-head {
|
|
display: none;
|
|
}
|
|
|
|
.viewer-workbar {
|
|
grid-template-columns: minmax(420px, 1fr) minmax(170px, .45fr) max-content;
|
|
gap: 12px;
|
|
padding: 0;
|
|
border: 0;
|
|
background: transparent;
|
|
}
|
|
|
|
.viewer-workbar .filter-select,
|
|
.viewer-workbar .input,
|
|
.viewer-workbar .icon-btn,
|
|
.workbar-group.sheet-tools .sheet-btn,
|
|
.header-export-row .btn,
|
|
.tab-btn {
|
|
min-height: 30px;
|
|
}
|
|
|
|
.workbar-group.search-tools {
|
|
grid-template-columns: minmax(160px, 210px) minmax(180px, 1fr) 36px minmax(110px, .55fr) 64px 36px;
|
|
}
|
|
.workbar-group.search-tools.has-purple-knight-filter {
|
|
grid-template-columns: minmax(140px, 190px) minmax(150px, 1fr) 36px minmax(150px, 190px) minmax(90px, .5fr) 64px 36px;
|
|
}
|
|
|
|
.workbar-group.sheet-tools .sheet-list {
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
.workbar-group.sheet-tools .sheet-btn {
|
|
min-width: 132px;
|
|
padding: 5px 8px;
|
|
}
|
|
|
|
.workbar-action {
|
|
gap: 10px;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.workbar-action .action-panel-row,
|
|
.workbar-action .action-toggle {
|
|
font-size: 12px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.viewer-body {
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.table-scroll,
|
|
.viewer-scroll {
|
|
height: 100%;
|
|
min-height: 0;
|
|
overflow: auto;
|
|
scrollbar-width: thin;
|
|
scrollbar-color: #777 #202021;
|
|
}
|
|
|
|
.table-scroll::-webkit-scrollbar,
|
|
.viewer-scroll::-webkit-scrollbar {
|
|
width: 10px;
|
|
height: 10px;
|
|
}
|
|
|
|
.table-scroll::-webkit-scrollbar-track,
|
|
.viewer-scroll::-webkit-scrollbar-track {
|
|
background: #202021;
|
|
}
|
|
|
|
.table-scroll::-webkit-scrollbar-thumb,
|
|
.viewer-scroll::-webkit-scrollbar-thumb {
|
|
background: #777;
|
|
border-radius: 999px;
|
|
border: 2px solid #202021;
|
|
}
|
|
|
|
.records-layout {
|
|
height: 100%;
|
|
min-height: 0;
|
|
}
|
|
|
|
@media (max-width: 1280px) {
|
|
.summary-strip {
|
|
grid-template-columns: repeat(4, minmax(72px, 1fr));
|
|
}
|
|
.summary-controls {
|
|
grid-column: 1 / -1;
|
|
}
|
|
.summary-mainline {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.summary-mainline .viewer-actions {
|
|
justify-content: start;
|
|
}
|
|
.viewer-workbar {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.workbar-action {
|
|
justify-content: flex-start;
|
|
flex-wrap: wrap;
|
|
}
|
|
}
|
|
|
|
/* Detail title should lead the reader view clearly above the metadata row. */
|
|
.record-article.reader-article h2 {
|
|
max-width: 1040px;
|
|
margin: 0 0 22px;
|
|
color: #f1eadf;
|
|
font-size: clamp(30px, 2.6vw, 44px);
|
|
font-weight: 760;
|
|
line-height: 1.08;
|
|
letter-spacing: 0;
|
|
text-transform: none;
|
|
}
|
|
|
|
.reader-all-fields .reader-meta-table th {
|
|
font-size: 14px;
|
|
line-height: 1.35;
|
|
}
|
|
|
|
.reader-all-fields .reader-meta-table td {
|
|
font-size: 20px;
|
|
line-height: 1.55;
|
|
}
|
|
|
|
.reader-all-fields .markdown-content,
|
|
.reader-all-fields .markdown-content table {
|
|
font-size: 20px;
|
|
line-height: 1.55;
|
|
}
|
|
|
|
.reader-all-fields .markdown-content th,
|
|
.reader-all-fields .markdown-content td {
|
|
font-size: 18px;
|
|
line-height: 1.45;
|
|
}
|
|
|
|
/* Keep Actionlist detail text aligned with the readable summary typography. */
|
|
.dashboard-card.full .dashboard-kv span {
|
|
font-size: 14px;
|
|
line-height: 1.35;
|
|
}
|
|
|
|
.dashboard-card.full .dashboard-value-cell,
|
|
.dashboard-card.full .markdown-content,
|
|
.review-field.full .markdown-preview,
|
|
.review-field.full .markdown-content {
|
|
font-size: 18px;
|
|
font-weight: 400;
|
|
line-height: 1.55;
|
|
}
|
|
|
|
.review-field.full .review-textarea {
|
|
font-size: 18px;
|
|
line-height: 1.55;
|
|
}
|
|
|
|
.dashboard-card.full .markdown-content table,
|
|
.review-field.full .markdown-content table {
|
|
font-size: 17px;
|
|
line-height: 1.45;
|
|
}
|
|
|
|
.dashboard-card.full .markdown-content th,
|
|
.dashboard-card.full .markdown-content td,
|
|
.review-field.full .markdown-content th,
|
|
.review-field.full .markdown-content td {
|
|
font-size: 17px;
|
|
font-weight: 400;
|
|
line-height: 1.45;
|
|
}
|
|
|
|
.dashboard-card.full .markdown-content th,
|
|
.review-field.full .markdown-content th {
|
|
font-weight: 650;
|
|
}
|
|
.workbar-group.sheet-tools .sheet-list {
|
|
overflow-x: auto;
|
|
scrollbar-width: thin;
|
|
}
|
|
|
|
.workbar-group.sheet-tools .sheet-list {
|
|
display: block;
|
|
overflow: visible;
|
|
}
|
|
|
|
.workbar-group.sheet-tools .sheet-select {
|
|
width: 100%;
|
|
min-width: 0;
|
|
}
|
|
.tile-grid {
|
|
padding: 16px;
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
gap: 14px;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.data-tile {
|
|
min-width: 0;
|
|
min-height: 188px;
|
|
padding: 17px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0;
|
|
border: 1px solid var(--line);
|
|
border-radius: 7px;
|
|
background: #202021;
|
|
color: inherit;
|
|
font: inherit;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.data-tile:hover,
|
|
.data-tile:focus-visible {
|
|
border-color: var(--accent-strong);
|
|
box-shadow: 0 0 0 2px rgba(167, 119, 255, .16);
|
|
outline: 0;
|
|
}
|
|
|
|
.data-tile-heading {
|
|
width: 100%;
|
|
min-width: 0;
|
|
padding: 0 0 13px;
|
|
border-bottom: 1px solid #343438;
|
|
background: transparent;
|
|
}
|
|
|
|
.data-tile-heading small {
|
|
display: block;
|
|
margin-bottom: 5px;
|
|
color: var(--accent-strong);
|
|
font-size: 11px;
|
|
font-weight: 650;
|
|
letter-spacing: .06em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.data-tile-heading h3 {
|
|
margin: 0;
|
|
color: #f1eadf;
|
|
font-size: 18px;
|
|
font-weight: 650;
|
|
line-height: 1.3;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.data-tile-fields {
|
|
display: grid;
|
|
gap: 0;
|
|
align-content: start;
|
|
}
|
|
|
|
.data-tile-field {
|
|
min-width: 0;
|
|
display: grid;
|
|
grid-template-columns: minmax(82px, .42fr) minmax(0, 1fr);
|
|
gap: 10px;
|
|
align-items: start;
|
|
padding: 9px 0;
|
|
border-bottom: 1px solid #343438;
|
|
}
|
|
|
|
.data-tile-field:last-child {
|
|
border-bottom: 0;
|
|
padding-bottom: 0;
|
|
}
|
|
|
|
.data-tile-field b {
|
|
color: var(--accent-strong);
|
|
font-size: 11px;
|
|
font-weight: 650;
|
|
line-height: 1.35;
|
|
text-transform: uppercase;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.data-tile-field span {
|
|
color: #f1eadf;
|
|
font-size: 14px;
|
|
line-height: 1.4;
|
|
overflow: hidden;
|
|
overflow-wrap: anywhere;
|
|
display: -webkit-box;
|
|
-webkit-box-orient: vertical;
|
|
-webkit-line-clamp: 3;
|
|
}
|
|
|
|
@media (max-width: 720px) {
|
|
.tile-grid { grid-template-columns: 1fr; padding: 12px; }
|
|
}
|
|
|
|
/* Refined editor surfaces: retain the dark text panels with a lighter visual frame. */
|
|
.edit-dashboard-view {
|
|
padding-top: 18px;
|
|
}
|
|
|
|
.edit-dashboard-view .dashboard-grid {
|
|
gap: 10px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.edit-dashboard-view .dashboard-card {
|
|
min-height: 0;
|
|
padding: 11px 12px;
|
|
border-color: #323235;
|
|
border-radius: 5px;
|
|
background: #1b1b1c;
|
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .018);
|
|
}
|
|
|
|
.edit-dashboard-view .dashboard-card:not(.full) {
|
|
min-height: 82px;
|
|
}
|
|
|
|
.edit-dashboard-view .review-field {
|
|
gap: 5px;
|
|
}
|
|
|
|
.edit-dashboard-view .review-field label,
|
|
.edit-dashboard-view .review-marker-label,
|
|
.edit-dashboard-view .dashboard-card > .reader-label {
|
|
font-size: 11px;
|
|
font-weight: 650;
|
|
letter-spacing: .045em;
|
|
}
|
|
|
|
.edit-dashboard-view .review-input,
|
|
.edit-dashboard-view .review-select,
|
|
.edit-dashboard-view .review-textarea {
|
|
min-height: 36px;
|
|
padding: 7px 9px;
|
|
border-color: #343438;
|
|
border-radius: 4px;
|
|
background: #121213;
|
|
font-size: 14px;
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.edit-dashboard-view .review-input:focus,
|
|
.edit-dashboard-view .review-select:focus,
|
|
.edit-dashboard-view .review-textarea:focus {
|
|
border-color: #6f54a8;
|
|
box-shadow: 0 0 0 1px rgba(157, 113, 255, .18);
|
|
outline: 0;
|
|
}
|
|
|
|
.edit-dashboard-view .edit-summary-card {
|
|
padding: 12px 13px 13px;
|
|
}
|
|
|
|
.edit-dashboard-view .edit-summary-card > .reader-label {
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.edit-dashboard-view .reader-summary-grid {
|
|
gap: 8px;
|
|
}
|
|
|
|
.edit-dashboard-view .reader-summary-block {
|
|
padding: 10px 12px;
|
|
border-color: #303033;
|
|
border-radius: 4px;
|
|
background: #131314;
|
|
box-shadow: none;
|
|
}
|
|
|
|
.edit-dashboard-view .reader-summary-field-heading {
|
|
margin-bottom: 6px;
|
|
font-size: 13px;
|
|
font-weight: 650;
|
|
letter-spacing: .045em;
|
|
}
|
|
|
|
.edit-dashboard-view .reader-summary {
|
|
font-size: 18px;
|
|
line-height: 1.52;
|
|
}
|
|
|
|
/* Security operations theme — visual layer only. */
|
|
:root {
|
|
--bg:#0b0c10;--panel:#12131a;--panel-soft:#171820;--line:#272934;--line-strong:#353846;
|
|
--text:#e8eaf2;--muted:#8b90a3;--accent:#8b5cf6;--accent-strong:#b99aff;
|
|
--accent-soft:rgba(139,92,246,.13);--soc-cyan:#2dd4bf;--soc-blue:#38bdf8;
|
|
--soc-amber:#fbbf24;--soc-orange:#fb923c;--soc-red:#fb4d6d;--soc-lime:#a3e635;
|
|
--shadow:none;--ui-label-weight:650;--ui-strong-weight:650;--ui-body-weight:400;--ui-label-tracking:.045em;
|
|
}
|
|
body{color:var(--text);background:#0b0c10;font-weight:400}
|
|
header{border-color:#262832;background:#0e0f14}
|
|
.brand{border-color:#262832;background:#111218}
|
|
.mark::after,h1{color:#f1f2f6;font-weight:650;letter-spacing:-.02em}
|
|
.mark::after{color:var(--accent-strong)}
|
|
.toolbar{gap:6px}
|
|
.btn,.icon-btn,.select,.input,.tab-btn,.sheet-btn{
|
|
min-height:32px;border:1px solid #2d303b;border-radius:3px;background:#15161d;color:#c7cad6;box-shadow:none;font-weight:450
|
|
}
|
|
.btn:hover,.icon-btn:hover,.select:hover,.input:hover,.tab-btn:hover,.sheet-btn:hover{
|
|
border-color:#59506f;background:#191a22;color:#f1f2f6
|
|
}
|
|
.btn:focus-visible,.icon-btn:focus-visible,.select:focus-visible,.input:focus-visible,.tab-btn:focus-visible,.sheet-btn:focus-visible{
|
|
border-color:var(--accent-strong);outline:1px solid rgba(185,154,255,.36);outline-offset:1px
|
|
}
|
|
.btn.primary{border-color:#7c4fe0;background:#7042d3;color:#fff}
|
|
.btn.primary:hover{background:#8154e5}
|
|
aside{border-color:#252730;background:#101117}
|
|
.drop-zone{border-color:#343744;border-radius:4px;background:#14151c}
|
|
.drop-zone:hover,.drop-zone.dragover{border-color:#67548d;background:#171720}
|
|
.drop-icon{color:var(--accent-strong);opacity:.78}
|
|
.side-section{border-color:#272934}
|
|
.side-section h2,.workspace h2,.reader-label,.field-name,.article-fact b,.profile-meta span,th,.article-kicker{
|
|
color:#9ea3b7;font-weight:650
|
|
}
|
|
.meta-row{color:#777d90;font-weight:400}
|
|
.meta-row b{color:#d9dce6;font-weight:500}
|
|
.workspace,.viewer,.viewer-body,.record-detail{background:#0b0c10}
|
|
.summary-strip{border-color:#272934;background:#0f1016}
|
|
.stat{position:relative;border-color:#272934;background:#12131a;padding-top:10px}
|
|
.stat::before{content:'';position:absolute;inset:0 0 auto;height:2px;background:var(--soc-blue);opacity:.9}
|
|
.stat:nth-child(2)::before{background:var(--soc-amber)}
|
|
.stat:nth-child(3)::before{background:var(--soc-orange)}
|
|
.stat:nth-child(4)::before{background:var(--soc-lime)}
|
|
.stat span{color:#858a9c;font-size:10px;font-weight:650;letter-spacing:.065em}
|
|
.stat strong{color:#f2f3f7;font-size:clamp(16px,1.25vw,21px);font-weight:650;overflow-wrap:anywhere}
|
|
.summary-controls{background:#101117}
|
|
.summary-controls .viewer-title strong{color:#e4e6ed;font-weight:550}
|
|
.summary-controls .viewer-title span{color:#7f8496;font-weight:400}
|
|
.view-tabs,.header-export-row{border-color:#2c2e39;border-radius:3px;background:#13141b}
|
|
.tab-btn,.header-export-row .btn{border-color:#2c2e39;background:transparent;color:#8f94a6}
|
|
.tab-btn.active{color:#f5f2ff;background:#252031;box-shadow:inset 0 -2px 0 var(--accent-strong)}
|
|
.viewer-workbar{gap:8px}
|
|
.viewer-workbar .filter-select,.viewer-workbar .input,.viewer-workbar .icon-btn,
|
|
.workbar-group.sheet-tools .sheet-btn,.workbar-group.sheet-tools .sheet-select{
|
|
min-width:0;border-color:#2d303b;background:#15161d;color:#d5d7e0;font-weight:450
|
|
}
|
|
.workbar-action{color:#9297a9}
|
|
.workbar-action .action-panel-row b{color:var(--accent-strong);font-weight:650}
|
|
.action-toggle{color:#b9bdca;font-weight:450}
|
|
.record-list{border-color:#252730;background:#0e0f14;padding:8px 7px}
|
|
.record-card{margin-bottom:5px;padding:10px 11px;border:1px solid transparent;border-left:2px solid transparent;border-radius:3px;background:transparent}
|
|
.record-card:hover{border-color:#282b35;background:#14151c}
|
|
.record-card.active{border-color:#34303f;border-left-color:var(--accent-strong);background:#181621;box-shadow:inset 0 0 0 1px rgba(139,92,246,.035)}
|
|
.record-title,.record-card.active .record-title{color:#dfe1e8;font-size:14px;font-weight:500;line-height:1.42}
|
|
.record-meta{color:#777d90;font-size:11px;font-weight:400}
|
|
.record-article.reader-article{color:#e5e7ee}
|
|
.reader-nav,.reader-crumbs{color:#858a9e;font-size:13px}
|
|
.reader-sticky-heading{border-color:#292b35;background:#0b0c10}
|
|
.record-article.reader-article h2{color:#f0f1f5;font-weight:620;letter-spacing:-.025em}
|
|
.reader-meta-line{gap:7px;color:#aeb2c0;font-family:inherit;font-size:13px;font-weight:500}
|
|
.reader-severity{
|
|
min-height:24px;padding:0 8px;border:1px solid #30333e;border-left:2px solid var(--accent-strong);
|
|
border-radius:2px;background:#111218;color:#d9dce5;font-size:11px;font-weight:650;letter-spacing:.04em
|
|
}
|
|
.reader-severity.severity-critical{background:#160d12;border-color:#6d2736;border-left-color:#ff4267;color:#ff9aae}
|
|
.reader-severity.severity-high{background:#16100d;border-color:#704020;border-left-color:#fb923c;color:#ffc18d}
|
|
.reader-severity.severity-medium{background:#16140b;border-color:#62551f;border-left-color:#facc15;color:#f9df78}
|
|
.reader-severity.severity-low{background:#0c1512;border-color:#275b4d;border-left-color:#2dd4a4;color:#8de8cb}
|
|
.reader-severity.severity-info{background:#0c1318;border-color:#24506b;border-left-color:#38bdf8;color:#9edcff}
|
|
.reader-mitre-line{gap:7px;margin-top:8px}
|
|
.reader-mitre-badge{border-left-color:var(--soc-cyan);color:#a8ede2}
|
|
.reader-bookmark-btn,.reader-mode-btn,.reader-results-btn{border-color:#313440;border-radius:3px;background:#121319;color:#c6b5ef}
|
|
.edit-dashboard-view .dashboard-grid{gap:7px}
|
|
.edit-dashboard-view .dashboard-card{padding:9px 10px;border-color:#282b34;border-radius:3px;background:#111218;box-shadow:none}
|
|
.edit-dashboard-view .dashboard-card:not(.full){min-height:76px}
|
|
.edit-dashboard-view .review-field label,.edit-dashboard-view .review-marker-label,.edit-dashboard-view .dashboard-card>.reader-label{
|
|
color:#a99acb;font-size:10px;font-weight:650
|
|
}
|
|
.edit-dashboard-view .review-input,.edit-dashboard-view .review-select,.edit-dashboard-view .review-textarea{
|
|
min-height:34px;border-color:#2c2f39;border-radius:2px;background:#0d0e12;color:#ececf1
|
|
}
|
|
.edit-dashboard-view .reader-summary-grid{gap:7px}
|
|
.edit-dashboard-view .reader-summary-block{padding:10px 11px;border-color:#282b34;border-radius:3px;background:#0d0e12}
|
|
.edit-dashboard-view .reader-summary-field-heading{color:#aa8df0;font-size:11px;font-weight:650}
|
|
.edit-dashboard-view .reader-summary{color:#e7e8ed;font-size:16px;font-weight:400;line-height:1.55}
|
|
.reader-metadata,.review-panel,.reader-topic-box,.reader-card,.profile-card,.article-section{
|
|
border-color:#292b35;border-radius:3px;background:#111218;box-shadow:none
|
|
}
|
|
.reader-meta-table,table{border-color:#292b35;background:#0e0f14;color:#dfe1e8}
|
|
th,td{
|
|
border-color:#292b35;padding:8px 10px;max-width:min(440px,46vw);white-space:normal;
|
|
overflow-wrap:anywhere;word-break:break-word
|
|
}
|
|
th{background:#171820;color:#aeb3c2;font-size:11px;font-weight:650}
|
|
td{background:#0f1015;color:#d9dce4;font-weight:400}
|
|
tbody tr:hover td{background:#15161d}
|
|
mark{border-radius:1px;background:rgba(45,212,191,.22);color:#d9fff8}
|
|
.tile-grid{gap:9px;padding:10px;grid-template-columns:repeat(auto-fill,minmax(270px,1fr))}
|
|
.data-tile{min-height:168px;padding:13px;border-color:#292b35;border-radius:3px;background:#111218}
|
|
.data-tile:hover,.data-tile:focus-visible{border-color:#665482;background:#15151d;box-shadow:inset 2px 0 0 var(--accent-strong)}
|
|
.data-tile-heading,.data-tile-field{border-color:#292b35}
|
|
.data-tile-heading h3{color:#e5e7ed;font-size:15px;font-weight:550}
|
|
.data-tile-heading small,.data-tile-field b{color:#9d8cc7;font-weight:650}
|
|
.data-tile-field span{color:#cfd2dc;font-weight:400}
|
|
pre,.tree{background:#0b0c10;color:#d6dae5;font-weight:400}
|
|
.viewer-scroll pre{font-size:17px;line-height:1.55}
|
|
.results-floating-window{border:1px solid #4b3c67;border-radius:4px;background:#0d0e12;box-shadow:0 18px 60px rgba(0,0,0,.48)}
|
|
.results-floating-head{border-color:#2b2d37;background:#15151c}
|
|
.results-floating-title{color:#ececf2;font-weight:600}
|
|
.results-floating-body,.linked-results-body{background:#0d0e12}
|
|
.linked-results-source{color:#aeb2c0;font-weight:500}
|
|
.notice,.empty-state{color:#8d92a4;background:#101117}
|
|
.empty-state svg{color:#5e526f}
|
|
.record-list,.record-detail,.viewer-body,.table-scroll,.viewer-scroll,aside{scrollbar-color:#454956 #111218}
|
|
.record-list::-webkit-scrollbar-track,.record-detail::-webkit-scrollbar-track,.viewer-body::-webkit-scrollbar-track,
|
|
.table-scroll::-webkit-scrollbar-track,.viewer-scroll::-webkit-scrollbar-track{background:#111218}
|
|
.record-list::-webkit-scrollbar-thumb,.record-detail::-webkit-scrollbar-thumb,.viewer-body::-webkit-scrollbar-thumb,
|
|
.table-scroll::-webkit-scrollbar-thumb,.viewer-scroll::-webkit-scrollbar-thumb{border-color:#111218;background:#454956}
|
|
/* Flat incident-list treatment for Records and Tiles; no glow effects. */
|
|
.app-shell *,.app-shell *::before,.app-shell *::after,.results-floating-window,.results-floating-window *{
|
|
box-shadow:none!important;text-shadow:none!important;filter:none!important
|
|
}
|
|
header{backdrop-filter:none!important}
|
|
.records-layout{grid-template-columns:clamp(350px,31vw,470px) minmax(0,1fr)}
|
|
.record-list{padding:6px;background:#101117}
|
|
.record-card{
|
|
position:relative;display:grid;grid-template-columns:42px minmax(0,1fr);grid-template-rows:auto auto;
|
|
column-gap:11px;row-gap:6px;min-height:76px;margin:0 0 5px;padding:11px 12px 11px 10px;
|
|
border:1px solid #292b35;border-radius:3px;background:#15161e;text-align:left
|
|
}
|
|
.record-card::before{
|
|
content:'';grid-column:1;grid-row:1/3;align-self:center;justify-self:center;width:30px;height:34px;
|
|
clip-path:polygon(50% 0,94% 24%,94% 76%,50% 100%,6% 76%,6% 24%);background:#2dd4bf
|
|
}
|
|
.record-card::after{
|
|
content:'';position:absolute;left:18px;top:50%;width:8px;height:4px;border-left:2px solid #07110f;
|
|
border-bottom:2px solid #07110f;transform:translateY(-65%) rotate(-45deg)
|
|
}
|
|
.record-card:hover{border-color:#3a3d48;background:#181922}
|
|
.record-card.active{border-color:#544265;border-left:2px solid #ff2b82;background:#191721}
|
|
.record-card.active::before{background:#ff2b82}
|
|
.record-title{
|
|
grid-column:2;grid-row:1;align-self:end;min-width:0;color:#e5e7ee;font-size:14px;font-weight:520;
|
|
line-height:1.35;overflow-wrap:anywhere
|
|
}
|
|
.record-meta{
|
|
grid-column:2;grid-row:2;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:3px 12px;
|
|
min-width:0;color:#8a8fa2;font-size:11px;line-height:1.35
|
|
}
|
|
.record-meta span{min-width:0;overflow-wrap:anywhere}
|
|
.action-pill{border-color:#66507e;border-radius:2px;padding:1px 5px;background:#17131d}
|
|
.tile-grid{display:grid;grid-template-columns:1fr;gap:5px;padding:6px;align-items:stretch}
|
|
.data-tile{
|
|
display:grid;grid-template-columns:minmax(210px,.62fr) minmax(0,1.7fr);min-height:112px;padding:0;gap:0;
|
|
border:1px solid #292b35;border-radius:3px;background:#15161e;overflow:hidden
|
|
}
|
|
.data-tile:hover,.data-tile:focus-visible{
|
|
border-color:#454955;background:#171820;outline:1px solid #5a4c69;outline-offset:-1px
|
|
}
|
|
.data-tile-heading{
|
|
position:relative;display:grid;align-content:center;gap:7px;min-width:0;padding:16px 18px 16px 64px;
|
|
border-right:1px solid #292b35;border-bottom:0;background:#13141b
|
|
}
|
|
.data-tile-heading::before{
|
|
content:'';position:absolute;left:20px;top:50%;width:30px;height:34px;
|
|
clip-path:polygon(50% 0,94% 24%,94% 76%,50% 100%,6% 76%,6% 24%);background:#ff2b82;transform:translateY(-50%)
|
|
}
|
|
.data-tile-heading small{margin:0;color:#9a8bad;font-size:10px;font-weight:600}
|
|
.data-tile-heading h3{color:#e6e8ee;font-size:15px;font-weight:520;line-height:1.35}
|
|
.data-tile-fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));align-content:stretch;min-width:0}
|
|
.data-tile-field{
|
|
display:grid;grid-template-columns:minmax(92px,.48fr) minmax(0,1fr);gap:8px;align-items:center;
|
|
min-width:0;padding:9px 12px;border-right:1px solid #292b35;border-bottom:1px solid #292b35
|
|
}
|
|
.data-tile-field:nth-child(2n){border-right:0}
|
|
.data-tile-field:nth-last-child(-n+2){border-bottom:0}
|
|
.data-tile-field b{color:#858a9e;font-size:10px;font-weight:600;letter-spacing:.035em}
|
|
.data-tile-field span{
|
|
display:block;color:#d8dae3;font-size:13px;font-weight:400;line-height:1.35;overflow:visible;
|
|
overflow-wrap:anywhere;-webkit-line-clamp:unset
|
|
}
|
|
@media(max-width:980px){
|
|
.records-layout{grid-template-columns:minmax(300px,42%) minmax(0,1fr)}
|
|
.data-tile{grid-template-columns:minmax(180px,.58fr) minmax(0,1.42fr)}
|
|
.data-tile-fields{grid-template-columns:1fr}
|
|
.data-tile-field,.data-tile-field:nth-child(2n),.data-tile-field:nth-last-child(-n+2){border-right:0;border-bottom:1px solid #292b35}
|
|
.data-tile-field:last-child{border-bottom:0}
|
|
}
|
|
/* Collision-safe command bar. */
|
|
.summary-strip,.summary-controls,.summary-mainline,.viewer-workbar,.workbar-group,.search-tools,.sheet-tools,.workbar-action{min-width:0}
|
|
@media(max-width:1800px){
|
|
.summary-strip{grid-template-columns:repeat(4,minmax(84px,1fr));min-height:0}
|
|
.summary-controls{grid-column:1/-1;width:100%;border-top:1px solid #272934}
|
|
.summary-mainline{grid-template-columns:minmax(0,1fr) max-content}
|
|
.viewer-workbar{grid-template-columns:minmax(0,1fr) minmax(230px,.38fr) max-content;align-items:center}
|
|
.workbar-group.search-tools.has-purple-knight-filter{
|
|
grid-template-columns:minmax(120px,.8fr) minmax(150px,1.35fr) 36px minmax(135px,.9fr) minmax(110px,.75fr) 64px 36px
|
|
}
|
|
}
|
|
@media(max-width:1380px){
|
|
.viewer-workbar{grid-template-columns:minmax(0,1fr)}
|
|
.summary-mainline{grid-template-columns:minmax(0,1fr)}
|
|
.summary-mainline .viewer-actions{justify-content:start}
|
|
.workbar-group.sheet-tools,.workbar-action{width:100%}
|
|
.workbar-action{justify-content:flex-start;flex-wrap:wrap}
|
|
}
|
|
@media(max-width:820px){
|
|
.summary-mainline{grid-template-columns:minmax(0,1fr)}
|
|
.summary-mainline .viewer-actions{justify-content:start}
|
|
.workbar-group.search-tools,.workbar-group.search-tools.has-purple-knight-filter{display:flex;flex-wrap:wrap;align-items:center}
|
|
.workbar-group.search-tools .filter-select,.workbar-group.search-tools .input{flex:1 1 190px;width:auto}
|
|
.workbar-group.search-tools .icon-btn{flex:0 0 36px}
|
|
}
|
|
|
|
/* Keep the complete workbar on one collision-free line at every width. */
|
|
.viewer-workbar{
|
|
display:flex;
|
|
flex-wrap:nowrap;
|
|
align-items:center;
|
|
overflow-x:auto;
|
|
overflow-y:hidden;
|
|
scrollbar-width:thin;
|
|
}
|
|
.workbar-group.search-tools,
|
|
.workbar-group.search-tools.has-purple-knight-filter{
|
|
display:flex;
|
|
flex:1 1 auto;
|
|
flex-wrap:nowrap;
|
|
align-items:center;
|
|
min-width:690px;
|
|
}
|
|
.workbar-group.search-tools #titleColumnSelect{flex:0 1 230px}
|
|
.workbar-group.search-tools #columnFilterSelect{flex:0 1 370px}
|
|
.workbar-group.search-tools #purpleKnightFilterSelect{flex:0 1 205px}
|
|
.workbar-group.search-tools #searchInput{flex:1 1 150px}
|
|
.workbar-group.search-tools .icon-btn,
|
|
.workbar-group.search-tools .search-result-nav{flex:0 0 auto}
|
|
.workbar-group.sheet-tools{flex:0 1 450px;min-width:230px}
|
|
.workbar-action{flex:0 0 auto;flex-wrap:nowrap!important}
|
|
.workbar-action .action-panel-row{min-width:auto}
|
|
|
|
/* Reference layout: header, full-width KPI row and dense command area. */
|
|
.app-shell{grid-template-rows:42px minmax(0,1fr)}
|
|
main{height:calc(100vh - 42px)}
|
|
.topbar{min-height:42px}
|
|
.brand{height:42px}
|
|
.topbar .select,.topbar .btn,.topbar .icon-btn{min-height:35px}
|
|
.summary-strip{
|
|
grid-template-columns:repeat(4,minmax(0,1fr));
|
|
min-height:0;
|
|
}
|
|
.stat{height:62px;padding:10px 14px}
|
|
.summary-controls{
|
|
grid-column:1/-1;
|
|
width:100%;
|
|
grid-template-rows:64px 34px;
|
|
gap:12px;
|
|
padding:12px 14px;
|
|
border-top:1px solid #272934;
|
|
}
|
|
.summary-mainline{grid-template-columns:minmax(0,1fr) max-content}
|
|
.summary-mainline .viewer-title{
|
|
align-self:stretch;
|
|
display:flex;
|
|
flex-direction:column;
|
|
justify-content:space-between;
|
|
}
|
|
.summary-mainline .viewer-actions{justify-content:end}
|
|
.viewer-workbar .filter-select,
|
|
.viewer-workbar .input,
|
|
.viewer-workbar .icon-btn,
|
|
.workbar-group.sheet-tools .sheet-select{min-height:34px}
|
|
.header-export-row .btn,.tab-btn{min-height:36px}
|
|
.records-layout{grid-template-columns:clamp(420px,26.5vw,520px) minmax(0,1fr)}
|
|
|
|
/* Compact header: context in KPI row, navigation and export in topbar. */
|
|
.topbar{
|
|
justify-content:flex-end;
|
|
padding:0 12px;
|
|
overflow-x:auto;
|
|
overflow-y:hidden;
|
|
}
|
|
.toolbar{
|
|
width:auto;
|
|
max-width:calc(100% - 220px);
|
|
margin-left:auto;
|
|
justify-content:flex-end;
|
|
flex-wrap:nowrap;
|
|
gap:6px;
|
|
}
|
|
.topbar-viewer-actions{
|
|
display:flex;
|
|
align-items:center;
|
|
justify-content:flex-end;
|
|
flex-wrap:nowrap;
|
|
gap:8px;
|
|
margin-left:8px;
|
|
}
|
|
.summary-strip{
|
|
grid-template-columns:repeat(4,minmax(86px,.7fr)) minmax(300px,1.8fr);
|
|
}
|
|
.context-stat{
|
|
min-width:0;
|
|
display:flex;
|
|
flex-direction:column;
|
|
justify-content:center;
|
|
}
|
|
.context-stat span{margin-bottom:5px}
|
|
.context-stat strong{
|
|
display:block;
|
|
min-width:0;
|
|
color:#e4e6ed;
|
|
font-size:13px;
|
|
font-weight:550;
|
|
line-height:1.2;
|
|
overflow:hidden;
|
|
text-overflow:ellipsis;
|
|
white-space:nowrap;
|
|
}
|
|
.context-stat small{
|
|
display:block;
|
|
min-width:0;
|
|
margin-top:4px;
|
|
color:#7f8496;
|
|
font-size:10px;
|
|
line-height:1.15;
|
|
overflow:hidden;
|
|
text-overflow:ellipsis;
|
|
white-space:nowrap;
|
|
}
|
|
.summary-controls{
|
|
grid-column:1/-1;
|
|
grid-template-rows:34px;
|
|
gap:0;
|
|
padding:10px 14px;
|
|
}
|
|
.topbar-viewer-actions .viewer-actions{justify-content:flex-end}
|
|
/* Severity-colored status hexagons in record and tile views. */
|
|
.record-card.severity-critical::before,.data-tile.severity-critical .data-tile-hex{background:#ff4267}
|
|
.record-card.severity-high::before,.data-tile.severity-high .data-tile-hex{background:#fb923c}
|
|
.record-card.severity-medium::before,.data-tile.severity-medium .data-tile-hex{background:#facc15}
|
|
.record-card.severity-low::before,.data-tile.severity-low .data-tile-hex{background:#a3e635}
|
|
.record-card.severity-info::before,.data-tile.severity-info .data-tile-hex{background:#38bdf8}
|
|
|
|
/* Compact card view: title, reader badges and bookmark only. */
|
|
.tile-grid{
|
|
display:grid;
|
|
grid-template-columns:repeat(auto-fill,minmax(300px,1fr));
|
|
gap:10px;
|
|
padding:10px;
|
|
align-items:stretch;
|
|
}
|
|
.data-tile{
|
|
position:relative;
|
|
display:grid;
|
|
grid-template-columns:38px minmax(0,1fr) 36px;
|
|
align-items:start;
|
|
gap:11px;
|
|
min-width:0;
|
|
min-height:122px;
|
|
padding:14px;
|
|
border:1px solid #292b35;
|
|
border-radius:3px;
|
|
background:#15161e;
|
|
color:#e6e8ee;
|
|
cursor:pointer;
|
|
overflow:hidden;
|
|
text-align:left;
|
|
}
|
|
.data-tile:hover,.data-tile:focus-visible{
|
|
border-color:#544265;
|
|
background:#191721;
|
|
outline:1px solid #6f5687;
|
|
outline-offset:-1px;
|
|
}
|
|
.data-tile-hex{
|
|
position:relative;
|
|
width:30px;
|
|
height:34px;
|
|
margin-top:1px;
|
|
clip-path:polygon(50% 0,94% 24%,94% 76%,50% 100%,6% 76%,6% 24%);
|
|
background:#2dd4bf;
|
|
}
|
|
.data-tile-hex::after{
|
|
content:'';
|
|
position:absolute;
|
|
left:9px;
|
|
top:14px;
|
|
width:8px;
|
|
height:4px;
|
|
border-left:2px solid #07110f;
|
|
border-bottom:2px solid #07110f;
|
|
transform:rotate(-45deg);
|
|
}
|
|
.data-tile-content{min-width:0}
|
|
.data-tile-content h3{
|
|
margin:0;
|
|
color:#e6e8ee;
|
|
font-size:15px;
|
|
font-weight:550;
|
|
line-height:1.4;
|
|
overflow-wrap:anywhere;
|
|
}
|
|
.data-tile-badges{
|
|
display:flex;
|
|
flex-wrap:wrap;
|
|
gap:7px;
|
|
margin-top:12px;
|
|
}
|
|
.data-tile-badges .reader-severity{
|
|
min-height:24px;
|
|
padding:0 8px;
|
|
font-size:11px;
|
|
line-height:1;
|
|
}
|
|
.tile-bookmark-btn{
|
|
width:36px;
|
|
height:36px;
|
|
align-self:start;
|
|
}
|
|
@media(max-width:760px){
|
|
.tile-grid{grid-template-columns:1fr}
|
|
}
|
|
/* Final compact workspace layout. */
|
|
.app-shell{grid-template-rows:48px minmax(0,1fr)}
|
|
main{height:calc(100vh - 48px)}
|
|
header{height:48px;overflow:hidden}
|
|
.topbar{
|
|
height:48px;min-height:48px;align-items:center;justify-content:flex-end;padding:0 10px;
|
|
overflow-x:auto;overflow-y:hidden;scrollbar-width:none
|
|
}
|
|
.topbar::-webkit-scrollbar{display:none}
|
|
.brand{height:48px}
|
|
.toolbar{
|
|
width:auto;max-width:none;min-width:max-content;margin-left:auto;
|
|
flex-wrap:nowrap;justify-content:flex-end;align-items:center
|
|
}
|
|
.topbar .select,.topbar .btn,.topbar .icon-btn,
|
|
.topbar .header-export-row .btn,.topbar .tab-btn{min-height:32px;height:32px}
|
|
.topbar-viewer-actions{height:32px;flex-wrap:nowrap}
|
|
|
|
aside{padding:0;overflow:auto}
|
|
.sidebar-kpis{display:grid;align-content:start;min-width:220px;background:#101117}
|
|
.sidebar-kpis .stat{
|
|
width:100%;height:auto;min-height:72px;padding:12px 14px;
|
|
border-width:0 0 1px;border-radius:0
|
|
}
|
|
.sidebar-kpis .context-stat{min-height:104px}
|
|
.sidebar-kpis .context-stat strong,
|
|
.sidebar-kpis .context-stat small{white-space:normal;overflow-wrap:anywhere}
|
|
|
|
.summary-strip{display:block;min-height:54px}
|
|
.summary-controls{display:block;width:100%;height:54px;padding:10px 12px;border-top:0}
|
|
.viewer-workbar{height:34px}
|
|
|
|
/* Compact Results popup typography. */
|
|
.results-floating-window{font-size:16px;line-height:1.45}
|
|
.results-floating-window .results-floating-title{font-size:17px;line-height:1.3}
|
|
.results-floating-window .results-floating-body{font-size:16px;line-height:1.45}
|
|
.results-floating-window .linked-results-source{font-size:16px;line-height:1.4}
|
|
.results-floating-window .linked-results-table,
|
|
.results-floating-window .linked-results-table th,
|
|
.results-floating-window .linked-results-table td{font-size:15px;line-height:1.4}
|
|
.results-floating-window .linked-results-empty{font-size:16px;line-height:1.45}
|
|
|
|
/* Import call-to-action shown before the first file is loaded. */
|
|
.empty-import-state{padding:32px;align-items:center;justify-items:center}
|
|
.empty-import-state>div{width:min(720px,100%);display:grid;justify-items:center;gap:24px}
|
|
.empty-import-state [data-empty-open]{min-width:150px}
|
|
.empty-support-grid{
|
|
width:100%;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;text-align:left
|
|
}
|
|
.empty-support-grid section{
|
|
padding:18px;border:1px solid #292b35;border-radius:3px;background:#12131a
|
|
}
|
|
.empty-support-grid strong{display:block;color:#e5e7ee;font-size:14px}
|
|
.empty-support-grid ul{margin:12px 0 0;padding-left:20px;color:#9da2b5;font-size:13px;line-height:1.65}
|
|
|
|
.data-tile-actions{display:grid;gap:8px;align-content:start}
|
|
.tile-results-btn{
|
|
width:36px;height:36px;border:1px solid #313440;border-radius:3px;
|
|
background:#121319;color:#c6b5ef;cursor:pointer;font-size:13px;font-weight:700
|
|
}
|
|
.tile-results-btn:hover{border-color:var(--accent-strong);color:var(--accent-strong)}
|
|
@media(max-width:720px){.empty-support-grid{grid-template-columns:1fr}}
|
|
|
|
/* Header counters and action-aware workspace button. */
|
|
.topbar-live-summary{
|
|
flex:1 1 auto;
|
|
min-width:320px;
|
|
height:38px;
|
|
display:flex;
|
|
align-items:stretch;
|
|
justify-content:center;
|
|
gap:0;
|
|
margin:0 12px;
|
|
padding:0;
|
|
border:1px solid #4b3c67;
|
|
border-radius:3px;
|
|
background:#12131a;
|
|
color:#c8cbd7;
|
|
font-size:13px;
|
|
font-weight:650;
|
|
letter-spacing:.025em;
|
|
white-space:nowrap;
|
|
}
|
|
.topbar-live-summary span{
|
|
min-width:170px;
|
|
display:flex;
|
|
align-items:center;
|
|
justify-content:center;
|
|
gap:10px;
|
|
padding:0 24px;
|
|
}
|
|
.topbar-live-summary span+span{border-left:1px solid #343744}
|
|
.topbar-live-summary b{
|
|
margin:0;
|
|
color:#38bdf8;
|
|
font-size:19px;
|
|
font-weight:750;
|
|
line-height:1;
|
|
}
|
|
.topbar-live-summary span:last-child b{color:#a3e635}
|
|
.workspace-btn:disabled{
|
|
border-color:#2c2f38;
|
|
background:#15161c;
|
|
color:#656a79;
|
|
cursor:not-allowed;
|
|
opacity:.72;
|
|
}
|
|
.workspace-btn.has-items{
|
|
border-color:#268a58;
|
|
background:#17643f;
|
|
color:#effff6;
|
|
opacity:1;
|
|
}
|
|
.workspace-btn.has-items:hover{border-color:#39b975;background:#1d7a4d}
|
|
.workspace-btn.has-items.is-active{
|
|
border-color:#72e3a6;
|
|
background:#238455;
|
|
color:#fff;
|
|
outline:1px solid #72e3a6;
|
|
outline-offset:1px;
|
|
}
|
|
|
|
/* Bookmark outline follows the tile severity. */
|
|
.data-tile.is-bookmarked{outline:2px solid #2dd4bf;outline-offset:-2px}
|
|
.data-tile.is-bookmarked.severity-critical{outline-color:#ff4267}
|
|
.data-tile.is-bookmarked.severity-high{outline-color:#fb923c}
|
|
.data-tile.is-bookmarked.severity-medium{outline-color:#facc15}
|
|
.data-tile.is-bookmarked.severity-low{outline-color:#a3e635}
|
|
.data-tile.is-bookmarked.severity-info{outline-color:#38bdf8}
|
|
|
|
/* Keep an unambiguous color link between each R button and its popup. */
|
|
.tile-results-btn.is-open{
|
|
border-color:var(--result-color);
|
|
background:var(--result-color);
|
|
color:#fff;
|
|
}
|
|
.tile-results-btn.is-open:hover{border-color:#fff;color:#fff}
|
|
.tile-results-window .results-floating-head{
|
|
border-bottom-color:var(--result-color);
|
|
background:var(--result-color);
|
|
color:#fff;
|
|
}
|
|
.tile-results-window .results-floating-title{color:#fff}
|
|
/* Requested detail, theme, and workspace refinements */
|
|
.theme-stat{min-height:88px}
|
|
.theme-switcher{display:grid;grid-template-columns:repeat(3,36px);gap:8px;align-items:center;margin-top:7px}
|
|
.theme-switcher button{display:grid;place-items:center;width:36px;height:36px;padding:0;border:1px solid #353846;border-radius:4px;background:#111218;color:#9da4b5;cursor:pointer}
|
|
.theme-switcher button:hover{border-color:#706286;color:#fff}
|
|
.theme-switcher button.active{border-color:#9b7bea;background:#2b2141;color:#fff;box-shadow:0 0 0 1px rgba(155,123,234,.22)}
|
|
.theme-switcher svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8}
|
|
|
|
.reader-title-line{display:grid;grid-template-columns:34px minmax(0,1fr);gap:13px;align-items:center}
|
|
.reader-title-hex{position:relative;display:block;width:30px;height:34px;clip-path:polygon(50% 0,93% 25%,93% 75%,50% 100%,7% 75%,7% 25%);background:#38bdf8}
|
|
.reader-title-hex::after{content:"";position:absolute;left:10px;top:9px;width:7px;height:12px;border:solid #071015;border-width:0 2px 2px 0;transform:rotate(45deg)}
|
|
.reader-title-hex.severity-critical{background:#ff4267}
|
|
.reader-title-hex.severity-high{background:#ff922b}
|
|
.reader-title-hex.severity-medium{background:#f6c945}
|
|
.reader-title-hex.severity-low{background:#43d17b}
|
|
.reader-title-hex.severity-info{background:#38bdf8}
|
|
.record-article.reader-article .reader-title-line h2{margin:0;font-size:clamp(23px,2vw,31px);line-height:1.14;letter-spacing:-.02em}
|
|
|
|
.edit-dashboard-view .dashboard-grid{display:block}
|
|
.edit-dashboard-view .dashboard-card,
|
|
.edit-dashboard-view .dashboard-card:not(.full){min-height:0;padding:18px 0;border:0;border-bottom:1px solid #292c35;border-radius:0;background:transparent;box-shadow:none}
|
|
.edit-dashboard-view .edit-summary-card{padding-top:20px}
|
|
.edit-dashboard-view .edit-summary-card>.reader-label{margin-bottom:4px;color:#fff;font-size:18px;font-weight:750;letter-spacing:.025em}
|
|
.edit-dashboard-view .review-field label,
|
|
.edit-dashboard-view .review-marker-label,
|
|
.edit-dashboard-view .dashboard-card>.reader-label{color:#fff;font-size:15px;font-weight:700}
|
|
.reader-summary-text-list{display:block}
|
|
.reader-summary-text{padding:16px 0;border-top:1px solid #292c35}
|
|
.reader-summary-text:first-child{border-top:0}
|
|
.reader-summary-text h3{margin:0 0 8px;color:#fff;font-size:16px;font-weight:750;line-height:1.25;letter-spacing:.025em;text-transform:uppercase}
|
|
.reader-summary-text p{margin:0;color:#e7e9ef;font-size:17px;font-weight:400;line-height:1.6;white-space:pre-wrap;overflow-wrap:anywhere}
|
|
|
|
.action-pill{border-color:#319767;background:#123728;color:#bcf7d5}
|
|
.record-card.is-workspace{outline:2px solid #38bdf8;outline-offset:-2px}
|
|
.record-card.is-workspace.severity-critical{outline-color:#ff4267}
|
|
.record-card.is-workspace.severity-high{outline-color:#ff922b}
|
|
.record-card.is-workspace.severity-medium{outline-color:#f6c945}
|
|
.record-card.is-workspace.severity-low{outline-color:#43d17b}
|
|
.record-card.is-workspace.severity-info{outline-color:#38bdf8}
|
|
|
|
html[data-theme="light"]{color-scheme:light;--bg:#eef1f5;--panel:#fff;--panel-soft:#f7f8fb;--line:#d7dbe4;--line-strong:#b8bfcc;--text:#171923;--muted:#626979}
|
|
html[data-theme="light"] body{background:#eef1f5;color:#171923}
|
|
html[data-theme="light"] header,
|
|
html[data-theme="light"] .brand,
|
|
html[data-theme="light"] aside,
|
|
html[data-theme="light"] .sidebar-kpis,
|
|
html[data-theme="light"] .summary-controls,
|
|
html[data-theme="light"] .viewer,
|
|
html[data-theme="light"] .viewer-body,
|
|
html[data-theme="light"] .record-detail,
|
|
html[data-theme="light"] .reader-sticky-heading,
|
|
html[data-theme="light"] .record-list{background:#fff;color:#171923;border-color:#d7dbe4}
|
|
html[data-theme="light"] .stat,
|
|
html[data-theme="light"] .record-card,
|
|
html[data-theme="light"] .data-tile,
|
|
html[data-theme="light"] .results-floating-window,
|
|
html[data-theme="light"] .results-floating-body{background:#fff;color:#171923;border-color:#d7dbe4}
|
|
html[data-theme="light"] .record-title,
|
|
html[data-theme="light"] .data-tile-content h3,
|
|
html[data-theme="light"] .record-article.reader-article h2,
|
|
html[data-theme="light"] .reader-summary-text h3,
|
|
html[data-theme="light"] .edit-dashboard-view .edit-summary-card>.reader-label,
|
|
html[data-theme="light"] .edit-dashboard-view .review-field label,
|
|
html[data-theme="light"] .edit-dashboard-view .review-marker-label,
|
|
html[data-theme="light"] .edit-dashboard-view .dashboard-card>.reader-label{color:#111827}
|
|
html[data-theme="light"] .reader-summary-text p,
|
|
html[data-theme="light"] .record-subtitle,
|
|
html[data-theme="light"] .reader-summary,
|
|
html[data-theme="light"] .linked-results-source{color:#273142}
|
|
html[data-theme="light"] .edit-dashboard-view .dashboard-card,
|
|
html[data-theme="light"] .reader-summary-text{border-color:#d7dbe4;background:transparent}
|
|
html[data-theme="light"] .select,
|
|
html[data-theme="light"] .input,
|
|
html[data-theme="light"] select,
|
|
html[data-theme="light"] input,
|
|
html[data-theme="light"] textarea,
|
|
html[data-theme="light"] button{border-color:#cbd1dc}
|
|
html[data-theme="light"] .select,
|
|
html[data-theme="light"] .input,
|
|
html[data-theme="light"] select,
|
|
html[data-theme="light"] input,
|
|
html[data-theme="light"] textarea{background:#fff;color:#171923}
|
|
html[data-theme="light"] .theme-switcher button{background:#f7f8fb;color:#596174;border-color:#cbd1dc}
|
|
html[data-theme="light"] .theme-switcher button.active{background:#e9e2fb;color:#4d258f;border-color:#7956c4}
|
|
html[data-theme="light"] .linked-results-table th{background:#eef1f5;color:#171923}
|
|
html[data-theme="light"] .linked-results-table td{background:#fff;color:#273142;border-color:#d7dbe4}
|
|
|
|
/* Complete the light palette for hard-coded surfaces in the security skin. */
|
|
html[data-theme="light"]{--accent:#7042d3;--accent-strong:#6840b5;--accent-soft:rgba(112,66,211,.1)}
|
|
html[data-theme="light"] .workspace,
|
|
html[data-theme="light"] .summary-strip{background:#eef1f5;border-color:#d7dbe4}
|
|
html[data-theme="light"] .brand{background:#f7f8fb}
|
|
html[data-theme="light"] .mark::after,
|
|
html[data-theme="light"] h1{color:#342050}
|
|
html[data-theme="light"] .subtitle{color:#73798a}
|
|
html[data-theme="light"] .topbar-live-summary{border-color:#c9bddf;background:#f7f5fb;color:#343746}
|
|
html[data-theme="light"] .topbar-live-summary span+span{border-color:#ddd6e9}
|
|
html[data-theme="light"] .view-tabs,
|
|
html[data-theme="light"] .header-export-row{border-color:#cbd1dc;background:#f3f5f8}
|
|
html[data-theme="light"] .btn,
|
|
html[data-theme="light"] .icon-btn,
|
|
html[data-theme="light"] .tab-btn,
|
|
html[data-theme="light"] .sheet-btn,
|
|
html[data-theme="light"] .header-export-row .btn,
|
|
html[data-theme="light"] .viewer-workbar .filter-select,
|
|
html[data-theme="light"] .viewer-workbar .input,
|
|
html[data-theme="light"] .viewer-workbar .icon-btn,
|
|
html[data-theme="light"] .workbar-group.sheet-tools .sheet-btn,
|
|
html[data-theme="light"] .workbar-group.sheet-tools .sheet-select{border-color:#cbd1dc;background:#fff;color:#343746}
|
|
html[data-theme="light"] .btn:hover,
|
|
html[data-theme="light"] .icon-btn:hover,
|
|
html[data-theme="light"] .select:hover,
|
|
html[data-theme="light"] .input:hover,
|
|
html[data-theme="light"] .tab-btn:hover,
|
|
html[data-theme="light"] .sheet-btn:hover{border-color:#8d73bd;background:#f3effa;color:#281a42}
|
|
html[data-theme="light"] .tab-btn.active{background:#e9e2f5;color:#3f246e}
|
|
html[data-theme="light"] .btn.primary{border-color:#7042d3;background:#7042d3;color:#fff}
|
|
html[data-theme="light"] .stat span,
|
|
html[data-theme="light"] .record-meta,
|
|
html[data-theme="light"] .workbar-action,
|
|
html[data-theme="light"] .action-toggle{color:#626979}
|
|
html[data-theme="light"] .stat strong,
|
|
html[data-theme="light"] .meta-row b{color:#171923}
|
|
html[data-theme="light"] .empty-state,
|
|
html[data-theme="light"] .notice{background:#fff;color:#687083}
|
|
html[data-theme="light"] .empty-state svg{color:#76599c}
|
|
html[data-theme="light"] .record-card:hover{background:#f6f7fa;border-color:#c9ced8}
|
|
html[data-theme="light"] .record-card.active{background:#f0ebf8;border-color:#a991c9}
|
|
html[data-theme="light"] .record-title,
|
|
html[data-theme="light"] .data-tile-heading h3{color:#111827}
|
|
html[data-theme="light"] .record-article.reader-article,
|
|
html[data-theme="light"] .data-tile-field span{color:#273142}
|
|
html[data-theme="light"] .edit-dashboard-view .review-input,
|
|
html[data-theme="light"] .edit-dashboard-view .review-select,
|
|
html[data-theme="light"] .edit-dashboard-view .review-textarea,
|
|
html[data-theme="light"] .edit-dashboard-view .reader-summary-block,
|
|
html[data-theme="light"] .reader-metadata,
|
|
html[data-theme="light"] .review-panel,
|
|
html[data-theme="light"] .reader-topic-box,
|
|
html[data-theme="light"] .reader-card,
|
|
html[data-theme="light"] .profile-card,
|
|
html[data-theme="light"] .article-section,
|
|
html[data-theme="light"] .empty-support-grid section{border-color:#d7dbe4;background:#f8f9fb;color:#273142}
|
|
html[data-theme="light"] .data-tile-heading{background:#f7f8fb;border-color:#d7dbe4}
|
|
html[data-theme="light"] .data-tile-field{border-color:#d7dbe4}
|
|
html[data-theme="light"] table{border-color:#d7dbe4;background:#fff;color:#273142}
|
|
html[data-theme="light"] th{border-color:#d7dbe4;background:#eef1f5;color:#343746}
|
|
html[data-theme="light"] td{border-color:#d7dbe4;background:#fff;color:#273142}
|
|
html[data-theme="light"] tbody tr:hover td{background:#f5f6f8}
|
|
html[data-theme="light"] pre,
|
|
html[data-theme="light"] .tree{background:#fff;color:#273142}
|
|
html[data-theme="light"] .results-floating-head{border-color:#d7dbe4;background:#f1edf7}
|
|
html[data-theme="light"] .results-floating-title{color:#281a42}
|
|
html[data-theme="light"] .linked-results-body{background:#fff}
|
|
html[data-theme="light"] .reader-bookmark-btn,
|
|
html[data-theme="light"] .reader-mode-btn,
|
|
html[data-theme="light"] .reader-results-btn,
|
|
html[data-theme="light"] .tile-results-btn{border-color:#cbd1dc;background:#fff;color:#56368f}
|
|
html[data-theme="light"] .reader-bookmark-btn:hover,
|
|
html[data-theme="light"] .reader-mode-btn:hover,
|
|
html[data-theme="light"] .reader-results-btn:hover,
|
|
html[data-theme="light"] .tile-results-btn:hover{background:#f3effa;color:#40246e}
|
|
html[data-theme="light"] .project-import-dialog{border-color:#cbd1dc;background:#fff;color:#171923}
|
|
html[data-theme="light"] .project-import-head,
|
|
html[data-theme="light"] .project-import-actions{border-color:#d7dbe4;background:#f7f8fb}
|
|
html[data-theme="light"] .project-import-summary{color:#343746}
|
|
html[data-theme="light"] .project-import-warning{background:#fff7df;color:#694f09}
|
|
html[data-theme="light"] .record-list,
|
|
html[data-theme="light"] .record-detail,
|
|
html[data-theme="light"] .viewer-body,
|
|
html[data-theme="light"] .table-scroll,
|
|
html[data-theme="light"] .viewer-scroll,
|
|
html[data-theme="light"] aside{scrollbar-color:#b9bfca #f1f3f6}
|
|
html[data-theme="light"] .record-list::-webkit-scrollbar-track,
|
|
html[data-theme="light"] .record-detail::-webkit-scrollbar-track,
|
|
html[data-theme="light"] .viewer-body::-webkit-scrollbar-track,
|
|
html[data-theme="light"] .table-scroll::-webkit-scrollbar-track,
|
|
html[data-theme="light"] .viewer-scroll::-webkit-scrollbar-track{background:#f1f3f6}
|
|
html[data-theme="light"] .record-list::-webkit-scrollbar-thumb,
|
|
html[data-theme="light"] .record-detail::-webkit-scrollbar-thumb,
|
|
html[data-theme="light"] .viewer-body::-webkit-scrollbar-thumb,
|
|
html[data-theme="light"] .table-scroll::-webkit-scrollbar-thumb,
|
|
html[data-theme="light"] .viewer-scroll::-webkit-scrollbar-thumb{border-color:#f1f3f6;background:#b9bfca}
|
|
/* Final contrast and interaction pass. */
|
|
.reader-severity.reader-mitre-badge{color:#fff}
|
|
.record-card.active .record-title{color:#fff}
|
|
.edit-dashboard-view .dashboard-grid{
|
|
display:grid;
|
|
grid-template-columns:repeat(4,minmax(0,1fr));
|
|
gap:12px
|
|
}
|
|
.edit-dashboard-view .dashboard-card.full{grid-column:1/-1}
|
|
.data-tile{border-width:2px;border-color:#4b4f5d}
|
|
.data-tile:hover,.data-tile:focus-visible{border-color:#8f6bb5;outline:1px solid #8f6bb5}
|
|
|
|
html[data-theme="light"]{
|
|
--bg:#e9edf3;--panel-soft:#f1f4f8;--line:#c3cbd8;--line-strong:#94a3b8;
|
|
--text:#0f172a;--muted:#475569
|
|
}
|
|
html[data-theme="light"] header,
|
|
html[data-theme="light"] aside,
|
|
html[data-theme="light"] .summary-controls,
|
|
html[data-theme="light"] .viewer,
|
|
html[data-theme="light"] .viewer-body,
|
|
html[data-theme="light"] .record-list,
|
|
html[data-theme="light"] .record-detail{border-color:#c3cbd8}
|
|
html[data-theme="light"] .btn,
|
|
html[data-theme="light"] .icon-btn,
|
|
html[data-theme="light"] .tab-btn,
|
|
html[data-theme="light"] .sheet-btn,
|
|
html[data-theme="light"] .header-export-row .btn,
|
|
html[data-theme="light"] .viewer-workbar .filter-select,
|
|
html[data-theme="light"] .viewer-workbar .input,
|
|
html[data-theme="light"] .viewer-workbar .icon-btn,
|
|
html[data-theme="light"] .workbar-group.sheet-tools .sheet-btn,
|
|
html[data-theme="light"] .workbar-group.sheet-tools .sheet-select{border-color:#aeb9c8;color:#1e293b}
|
|
html[data-theme="light"] tbody tr:nth-child(even) td,
|
|
html[data-theme="light"] .project-import-table tbody tr:nth-child(even) td{background:#eef2f7;color:#172033}
|
|
html[data-theme="light"] tbody tr:nth-child(odd) td,
|
|
html[data-theme="light"] .project-import-table tbody tr:nth-child(odd) td{background:#fff;color:#172033}
|
|
html[data-theme="light"] tbody tr:hover td{background:#e5ddf3;color:#172033}
|
|
html[data-theme="light"] .reader-severity.reader-mitre-badge{color:#fff}
|
|
html[data-theme="light"] .record-card{border-color:#b7c1d0}
|
|
html[data-theme="light"] .record-card.active{border:2px solid #7956a8;background:#e5dcf2}
|
|
html[data-theme="light"] .record-card.active .record-title{color:#172033}
|
|
html[data-theme="light"] .record-card.active .record-meta{color:#475569}
|
|
html[data-theme="light"] .data-tile{border:2px solid #a7b2c2}
|
|
html[data-theme="light"] .data-tile:hover,
|
|
html[data-theme="light"] .data-tile:focus-visible{border-color:#72509f;outline:1px solid #72509f;background:#f1ecf8}
|
|
html[data-theme="light"] .workspace-btn.has-items,
|
|
html[data-theme="light"] .workspace-btn.has-items:hover,
|
|
html[data-theme="light"] .workspace-btn.has-items.is-active{
|
|
border-color:#167347;background:#17824f;color:#fff;opacity:1
|
|
}
|
|
@media(max-width:720px){
|
|
.edit-dashboard-view .dashboard-grid{grid-template-columns:1fr}
|
|
}
|
|
/* Spacious badges, minimal theme controls and inset tile presentation. */
|
|
.brand{padding-left:18px}
|
|
.sidebar-kpis .stat{padding-left:20px;padding-right:18px}
|
|
.theme-stat{min-height:70px}
|
|
.theme-stat>span{display:none}
|
|
.theme-switcher{
|
|
grid-template-columns:repeat(3,32px);
|
|
gap:12px;
|
|
margin-top:0
|
|
}
|
|
.theme-switcher button,
|
|
html[data-theme="light"] .theme-switcher button{
|
|
width:32px;
|
|
height:32px;
|
|
border:0!important;
|
|
border-radius:0;
|
|
background:transparent!important;
|
|
color:var(--muted);
|
|
opacity:.62;
|
|
transition:color .16s ease,opacity .16s ease,transform .16s ease
|
|
}
|
|
.theme-switcher button:hover,
|
|
html[data-theme="light"] .theme-switcher button:hover{
|
|
border:0!important;
|
|
background:transparent!important;
|
|
color:var(--text);
|
|
opacity:.88
|
|
}
|
|
.theme-switcher button.active,
|
|
html[data-theme="light"] .theme-switcher button.active{
|
|
border:0!important;
|
|
background:transparent!important;
|
|
color:var(--accent-strong);
|
|
opacity:1;
|
|
transform:scale(1.18)
|
|
}
|
|
.theme-switcher button.active svg{stroke-width:2.25}
|
|
|
|
.reader-sticky-heading .reader-meta-line{margin-top:17px}
|
|
.reader-mitre-line{margin-top:15px}
|
|
.reader-sticky-heading .reader-meta-line>.reader-severity,
|
|
.data-tile-badges .reader-severity{
|
|
min-height:28px;
|
|
height:auto;
|
|
padding:5px 10px;
|
|
line-height:1.18;
|
|
white-space:normal
|
|
}
|
|
.data-tile-badges{gap:8px;margin-top:17px}
|
|
|
|
.tile-grid{gap:14px;padding:16px;background:#090a0e}
|
|
.data-tile{
|
|
min-height:136px;
|
|
padding:16px;
|
|
border-color:#555b69;
|
|
box-shadow:inset 0 0 0 1px rgba(255,255,255,.025),0 1px 3px rgba(0,0,0,.34)!important
|
|
}
|
|
html[data-theme="light"] .reader-sticky-heading .reader-meta-line>.reader-severity,
|
|
html[data-theme="light"] .data-tile-badges .reader-severity{
|
|
border-color:#aeb8c6;
|
|
background:#fff;
|
|
color:#0b0f19
|
|
}
|
|
html[data-theme="light"] .reader-sticky-heading .reader-meta-line>.reader-severity.severity-critical,
|
|
html[data-theme="light"] .reader-sticky-heading .reader-meta-line>.reader-severity.severity-high,
|
|
html[data-theme="light"] .reader-sticky-heading .reader-meta-line>.reader-severity.severity-medium,
|
|
html[data-theme="light"] .reader-sticky-heading .reader-meta-line>.reader-severity.severity-low,
|
|
html[data-theme="light"] .reader-sticky-heading .reader-meta-line>.reader-severity.severity-info,
|
|
html[data-theme="light"] .data-tile-badges .reader-severity.severity-critical,
|
|
html[data-theme="light"] .data-tile-badges .reader-severity.severity-high,
|
|
html[data-theme="light"] .data-tile-badges .reader-severity.severity-medium,
|
|
html[data-theme="light"] .data-tile-badges .reader-severity.severity-low,
|
|
html[data-theme="light"] .data-tile-badges .reader-severity.severity-info{background:#fff;color:#0b0f19}
|
|
html[data-theme="light"] .tile-grid{background:#dfe5ed}
|
|
html[data-theme="light"] .data-tile{
|
|
border-color:#9ca9ba;
|
|
background:#fff;
|
|
box-shadow:inset 0 0 0 1px #fff,0 1px 3px rgba(36,48,65,.18)!important
|
|
}
|
|
html[data-theme="light"] .data-tile:hover,
|
|
html[data-theme="light"] .data-tile:focus-visible{background:#faf8fd;border-color:#684294}
|
|
html[data-theme="light"] .tile-results-btn.is-open{
|
|
border-color:var(--result-color);
|
|
background:var(--result-color);
|
|
color:#fff
|
|
}
|
|
html[data-theme="light"] .tile-results-btn.is-open:hover{
|
|
border-color:#fff;background:var(--result-color);color:#fff
|
|
}
|
|
/* Raised neumorphic theme controls inspired by the supplied reference. */
|
|
.theme-stat{min-height:86px}
|
|
.theme-switcher{
|
|
grid-template-columns:repeat(3,42px);
|
|
gap:14px;
|
|
padding:6px 2px 10px
|
|
}
|
|
.theme-switcher button,
|
|
html[data-theme="light"] .theme-switcher button{
|
|
width:42px;
|
|
height:42px;
|
|
border:0!important;
|
|
border-radius:50%;
|
|
background:#181a20!important;
|
|
color:#b7bdc9;
|
|
opacity:1;
|
|
transform:none;
|
|
box-shadow:6px 6px 12px #07080b,-5px -5px 11px #252832!important;
|
|
transition:color .16s ease,transform .16s ease,box-shadow .16s ease
|
|
}
|
|
.theme-switcher button:hover,
|
|
html[data-theme="light"] .theme-switcher button:hover{
|
|
border:0!important;
|
|
background:#1b1d24!important;
|
|
color:#fff;
|
|
opacity:1;
|
|
transform:translateY(-1px);
|
|
box-shadow:7px 7px 14px #07080b,-6px -6px 12px #272a35!important
|
|
}
|
|
.theme-switcher button.active,
|
|
html[data-theme="light"] .theme-switcher button.active{
|
|
border:0!important;
|
|
background:#17191f!important;
|
|
color:var(--accent-strong);
|
|
opacity:1;
|
|
transform:none;
|
|
box-shadow:inset 4px 4px 8px #08090d,inset -4px -4px 8px #262933!important
|
|
}
|
|
.theme-switcher svg{width:20px;height:20px}
|
|
.theme-switcher button.active svg{width:22px;height:22px;stroke-width:2.25}
|
|
|
|
html[data-theme="light"] .theme-switcher button{
|
|
background:#edf1f6!important;
|
|
color:#596473;
|
|
box-shadow:6px 6px 12px #c4cbd5,-6px -6px 12px #fff!important
|
|
}
|
|
html[data-theme="light"] .theme-switcher button:hover{
|
|
background:#f0f3f7!important;
|
|
color:#202733;
|
|
box-shadow:7px 7px 14px #c1c8d2,-7px -7px 14px #fff!important
|
|
}
|
|
html[data-theme="light"] .theme-switcher button.active{
|
|
background:#e9edf3!important;
|
|
color:#6037a0;
|
|
box-shadow:inset 4px 4px 8px #c5ccd5,inset -4px -4px 8px #fff!important
|
|
}
|
|
@media(prefers-reduced-motion:reduce){
|
|
.theme-switcher button{transition:none}
|
|
}
|
|
/* Neumorphic tile cards matching the theme controls. */
|
|
.tile-grid{
|
|
gap:18px;
|
|
padding:20px;
|
|
background:#101117
|
|
}
|
|
.data-tile{
|
|
min-height:142px;
|
|
padding:18px;
|
|
border:0!important;
|
|
border-radius:12px;
|
|
background:#181a20;
|
|
box-shadow:7px 7px 14px #07080b,-5px -5px 12px #242731!important;
|
|
transition:transform .16s ease,box-shadow .16s ease,background .16s ease
|
|
}
|
|
.data-tile:hover,
|
|
.data-tile:focus-visible{
|
|
border:0!important;
|
|
border-color:transparent;
|
|
border-radius:12px;
|
|
background:#1b1d24;
|
|
outline:2px solid #6f5687;
|
|
outline-offset:2px;
|
|
transform:translateY(-2px);
|
|
box-shadow:9px 9px 18px #06070a,-7px -7px 14px #282b36!important
|
|
}
|
|
html[data-theme="light"] .tile-grid{background:#e7ecf2}
|
|
html[data-theme="light"] .data-tile{
|
|
border:0!important;
|
|
border-radius:12px;
|
|
background:#edf1f6;
|
|
box-shadow:7px 7px 14px #c3cad4,-7px -7px 14px #fff!important
|
|
}
|
|
html[data-theme="light"] .data-tile:hover,
|
|
html[data-theme="light"] .data-tile:focus-visible{
|
|
border:0!important;
|
|
background:#f0f3f7;
|
|
outline:2px solid #7653a4;
|
|
outline-offset:2px;
|
|
box-shadow:9px 9px 18px #bec6d1,-9px -9px 18px #fff!important
|
|
}
|
|
@media(prefers-reduced-motion:reduce){
|
|
.data-tile{transition:none}
|
|
.data-tile:hover,.data-tile:focus-visible{transform:none}
|
|
}
|
|
.data-tile.is-bookmarked{outline:2px solid #2dd4bf;outline-offset:2px}
|
|
.data-tile.is-bookmarked.severity-critical{outline-color:#ff4267}
|
|
.data-tile.is-bookmarked.severity-high{outline-color:#fb923c}
|
|
.data-tile.is-bookmarked.severity-medium{outline-color:#facc15}
|
|
.data-tile.is-bookmarked.severity-low{outline-color:#84cc16}
|
|
.data-tile.is-bookmarked.severity-info{outline-color:#38bdf8}
|
|
/* Global color policy: colored states use saturated solid tones, never pastels. */
|
|
.tile-results-btn.is-open,
|
|
html[data-theme="light"] .tile-results-btn.is-open,
|
|
.tile-results-btn.is-open:hover,
|
|
html[data-theme="light"] .tile-results-btn.is-open:hover{
|
|
border-color:var(--result-color)!important;
|
|
background:var(--result-color)!important;
|
|
color:#fff!important
|
|
}
|
|
.tile-results-window,
|
|
html[data-theme="light"] .tile-results-window{
|
|
border:2px solid var(--result-color)!important
|
|
}
|
|
.tile-results-window .results-floating-head,
|
|
html[data-theme="light"] .tile-results-window .results-floating-head{
|
|
border-color:var(--result-color)!important;
|
|
background:var(--result-color)!important;
|
|
color:#fff!important
|
|
}
|
|
.tile-results-window .results-floating-title,
|
|
html[data-theme="light"] .tile-results-window .results-floating-title,
|
|
.tile-results-window .results-floating-close,
|
|
html[data-theme="light"] .tile-results-window .results-floating-close{color:#fff!important}
|
|
|
|
.reader-severity.severity-critical,
|
|
html[data-theme="light"] .reader-sticky-heading .reader-meta-line>.reader-severity.severity-critical,
|
|
html[data-theme="light"] .data-tile-badges .reader-severity.severity-critical{
|
|
border-color:#be123c;background:#be123c;color:#fff
|
|
}
|
|
.reader-severity.severity-high,
|
|
html[data-theme="light"] .reader-sticky-heading .reader-meta-line>.reader-severity.severity-high,
|
|
html[data-theme="light"] .data-tile-badges .reader-severity.severity-high{
|
|
border-color:#c2410c;background:#c2410c;color:#fff
|
|
}
|
|
.reader-severity.severity-medium,
|
|
html[data-theme="light"] .reader-sticky-heading .reader-meta-line>.reader-severity.severity-medium,
|
|
html[data-theme="light"] .data-tile-badges .reader-severity.severity-medium{
|
|
border-color:#ca8a04;background:#ca8a04;color:#111827
|
|
}
|
|
.reader-severity.severity-low,
|
|
html[data-theme="light"] .reader-sticky-heading .reader-meta-line>.reader-severity.severity-low,
|
|
html[data-theme="light"] .data-tile-badges .reader-severity.severity-low{
|
|
border-color:#15803d;background:#15803d;color:#fff
|
|
}
|
|
.reader-severity.severity-info,
|
|
html[data-theme="light"] .reader-sticky-heading .reader-meta-line>.reader-severity.severity-info,
|
|
html[data-theme="light"] .data-tile-badges .reader-severity.severity-info{
|
|
border-color:#0369a1;background:#0369a1;color:#fff
|
|
}
|
|
.tab-btn.active,
|
|
html[data-theme="light"] .tab-btn.active,
|
|
.sheet-btn.active,
|
|
html[data-theme="light"] .sheet-btn.active{
|
|
border-color:#5b21b6;background:#5b21b6;color:#fff
|
|
}
|
|
.record-card.active,
|
|
html[data-theme="light"] .record-card.active{
|
|
border-color:#5b21b6;background:#5b21b6;color:#fff
|
|
}
|
|
.record-card.active .record-title,
|
|
html[data-theme="light"] .record-card.active .record-title,
|
|
.record-card.active .record-meta,
|
|
html[data-theme="light"] .record-card.active .record-meta{color:#fff}
|
|
.action-pill,
|
|
html[data-theme="light"] .action-pill{
|
|
border-color:#166534;background:#166534;color:#fff
|
|
}
|
|
mark,
|
|
html[data-theme="light"] mark{
|
|
background:#0f766e;color:#fff
|
|
}
|
|
.project-import-warning,
|
|
html[data-theme="light"] .project-import-warning{
|
|
border-left-color:#a16207;background:#a16207;color:#fff
|
|
}
|
|
html[data-theme="light"] tbody tr:hover td{background:#d5dbe4;color:#0f172a}
|
|
html[data-theme="light"] .topbar-live-summary{background:#f1f5f9}
|
|
html[data-theme="light"] .btn:hover,
|
|
html[data-theme="light"] .icon-btn:hover,
|
|
html[data-theme="light"] .select:hover,
|
|
html[data-theme="light"] .input:hover,
|
|
html[data-theme="light"] .sheet-btn:hover{
|
|
border-color:#5b21b6;background:#e2e8f0;color:#0f172a
|
|
}
|
|
html[data-theme="light"] .data-tile:hover,
|
|
html[data-theme="light"] .data-tile:focus-visible{
|
|
background:#f1f5f9;outline-color:#5b21b6
|
|
}
|
|
html[data-theme="light"] .theme-switcher button.active{
|
|
color:#5b21b6
|
|
}
|
|
html[data-theme="light"] .tab-btn:hover,
|
|
html[data-theme="light"] .reader-bookmark-btn:hover,
|
|
html[data-theme="light"] .reader-mode-btn:hover,
|
|
html[data-theme="light"] .reader-results-btn:hover,
|
|
html[data-theme="light"] .tile-results-btn:not(.is-open):hover{
|
|
border-color:#5b21b6;background:#e2e8f0;color:#0f172a
|
|
}
|
|
.reader-bookmark-btn[aria-pressed="true"],
|
|
html[data-theme="light"] .reader-bookmark-btn[aria-pressed="true"]{
|
|
border-color:#5b21b6;background:#5b21b6;color:#fff
|
|
}
|
|
.drop-zone.dragover,
|
|
html[data-theme="light"] .drop-zone.dragover{
|
|
border-color:#5b21b6;background:#5b21b6;color:#fff
|
|
}
|
|
.drop-zone.dragover strong,
|
|
.drop-zone.dragover span,
|
|
.drop-zone.dragover .drop-icon{color:#fff}
|
|
html[data-theme="light"] .results-floating-head{
|
|
border-color:#5b21b6;background:#5b21b6;color:#fff
|
|
}
|
|
html[data-theme="light"] .results-floating-title,
|
|
html[data-theme="light"] .results-floating-close{color:#fff}
|
|
html[data-theme="light"] .tile-results-window .results-floating-head{
|
|
border-color:var(--result-color)!important;
|
|
background:var(--result-color)!important
|
|
}
|
|
html[data-theme="light"] .tile-results-window .results-floating-title,
|
|
html[data-theme="light"] .tile-results-window .results-floating-close{color:#fff!important}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="app-shell">
|
|
<header>
|
|
<div class="topbar">
|
|
<div class="brand" aria-label="exxTableReader">
|
|
<div class="mark">exx</div>
|
|
<div>
|
|
<h1>exxTableReader</h1>
|
|
<div class="subtitle">Excel, JSON, XML und CSV übersichtlich lesen</div>
|
|
</div>
|
|
</div>
|
|
<div class="topbar-live-summary" aria-live="polite">
|
|
<span>Angezeigt <b id="visibleHeaderCount">0</b></span>
|
|
<span>Markiert <b id="markedHeaderCount">0</b></span>
|
|
</div>
|
|
<div class="toolbar">
|
|
<button class="btn" id="snapshotBtn" type="button">Snapshot</button>
|
|
<select class="select" id="delimiterSelect" title="CSV-Trennzeichen">
|
|
<option value="auto">CSV: Auto</option>
|
|
<option value=",">Komma</option>
|
|
<option value=";">Semikolon</option>
|
|
<option value="\t">Tab</option>
|
|
<option value="|">Pipe</option>
|
|
</select>
|
|
<button class="icon-btn" id="copyBtn" title="Aktuelle Ansicht kopieren" aria-label="Aktuelle Ansicht kopieren">
|
|
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path fill="currentColor" d="M16 1H4a2 2 0 0 0-2 2v12h2V3h12V1Zm3 4H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2Zm0 16H8V7h11v14Z"/></svg>
|
|
</button>
|
|
<button class="icon-btn" id="downloadBtn" title="Als CSV exportieren" aria-label="Als CSV exportieren">
|
|
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path fill="currentColor" d="M5 20h14v-2H5v2ZM13 4h-2v8.17L7.41 8.59 6 10l6 6 6-6-1.41-1.41L13 12.17V4Z"/></svg>
|
|
</button>
|
|
<button class="btn primary" id="openBtn">Datei öffnen</button>
|
|
<button class="btn workspace-btn" id="myWorkspaceBtn" type="button" disabled>my Workspace</button>
|
|
<div class="viewer-actions topbar-viewer-actions">
|
|
<div class="view-tabs" role="tablist" aria-label="Ansicht">
|
|
<button class="tab-btn" type="button" data-view="records">Datensätze</button>
|
|
<button class="tab-btn active" type="button" data-view="columns">Kacheln</button>
|
|
<button class="tab-btn" type="button" data-view="table">Raster</button>
|
|
<button class="tab-btn" type="button" data-view="raw">Rohdaten</button>
|
|
</div>
|
|
<div class="header-export-row" aria-label="Actionlist exportieren">
|
|
<button class="btn" type="button" data-action-export="csv">CSV</button>
|
|
<button class="btn" type="button" data-action-export="md">MD</button>
|
|
<button class="btn" type="button" data-action-export="html">HTML</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div hidden aria-hidden="true">
|
|
<input class="hidden-input" id="fileInput" type="file" multiple accept=".xlsx,.xls,.csv,.json,.exx-snapshot.json,.xml,.txt,.html,.htm,application/json,text/csv,text/xml,application/xml,text/html">
|
|
<button id="pickBtn" type="button"></button>
|
|
<div id="dropZone"></div>
|
|
<span id="fileName">-</span>
|
|
<span id="fileType">-</span>
|
|
<span id="fileSize">-</span>
|
|
<span id="fileStatus">Bereit</span>
|
|
<div id="excelNotice"></div>
|
|
<span id="viewerTitle"></span>
|
|
<span id="viewerHint"></span>
|
|
</div>
|
|
|
|
<main>
|
|
<button class="sidebar-toggle" id="sidebarToggle" type="button" aria-label="Sidebar einklappen" title="Sidebar einklappen"></button>
|
|
<aside>
|
|
<div class="sidebar-kpis" aria-label="Zusammenfassung">
|
|
<div class="stat"><span>Datensätze</span><strong id="rowCount">0</strong></div>
|
|
<div class="stat"><span>Felder</span><strong id="columnCount">0</strong></div>
|
|
<div class="stat"><span>Blätter</span><strong id="sheetCount">0</strong></div>
|
|
<div class="stat"><span>Format</span><strong id="formatLabel">-</strong></div>
|
|
<div class="stat theme-stat">
|
|
<span>Darstellung</span>
|
|
<div class="theme-switcher" role="group" aria-label="Farbschema">
|
|
<button type="button" data-theme-mode="dark" title="Darkmode" aria-label="Darkmode"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20.5 15.2A8 8 0 0 1 8.8 3.5 8.7 8.7 0 1 0 20.5 15.2Z" fill="currentColor"/></svg></button>
|
|
<button type="button" data-theme-mode="light" title="Lightmode" aria-label="Lightmode"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4" fill="currentColor"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9 7 7M17 17l2.1 2.1M19.1 4.9 17 7M7 17l-2.1 2.1" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></button>
|
|
<button type="button" data-theme-mode="system" title="Systemmode" aria-label="Systemmode"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="13" rx="1.5" fill="none" stroke="currentColor" stroke-width="2"/><path d="M8 21h8M12 17v4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<section class="workspace">
|
|
<div class="summary-strip" aria-label="Zusammenfassung">
|
|
<div class="summary-controls">
|
|
<div class="viewer-workbar" aria-label="Arbeitsleiste">
|
|
<div class="workbar-group search-tools">
|
|
<select class="select filter-select" id="titleColumnSelect" title="Spalte als Überschrift verwenden">
|
|
<option value="">Titel (auto)</option>
|
|
</select>
|
|
<select class="select filter-select" id="columnFilterSelect" title="Nach numerischer Spalte global sortieren">
|
|
<option value="all">Keine numerische Sortierung</option>
|
|
</select>
|
|
<button class="icon-btn" id="sortDirectionBtn" type="button" title="Absteigend sortieren" aria-label="Absteigend sortieren">↓</button>
|
|
<select class="select filter-select" id="purpleKnightFilterSelect" title="PurpleKnight-Ergebnis filtern" hidden>
|
|
<option value="all">Alle Ergebnisse</option>
|
|
<option value="evaluated">Evaluated</option>
|
|
<option value="not-selected">Not selected</option>
|
|
<option value="ioes-found">IOEs found</option>
|
|
<option value="passed">Passed</option>
|
|
<option value="failed-to-run">Failed to run</option>
|
|
<option value="not-relevant">Not Relevant</option>
|
|
<option value="canceled">Canceled</option>
|
|
</select> <input class="input" id="searchInput" placeholder="Filtertext">
|
|
<div class="search-result-nav" aria-label="Suchergebnisse durchlaufen">
|
|
<button class="icon-btn" id="searchPreviousBtn" type="button" title="Vorheriger Suchtreffer" aria-label="Vorheriger Suchtreffer">↑</button>
|
|
<button class="icon-btn" id="searchNextBtn" type="button" title="Nächster Suchtreffer" aria-label="Nächster Suchtreffer">↓</button>
|
|
</div>
|
|
<button class="icon-btn" id="clearSearchBtn" title="Suche löschen" aria-label="Suche löschen">
|
|
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path fill="currentColor" d="m18.3 5.71-1.41-1.42L12 9.17 7.11 4.29 5.7 5.71 10.59 10.6 5.7 15.49l1.41 1.42L12 12.01l4.89 4.9 1.41-1.42-4.89-4.89 4.89-4.89Z"/></svg>
|
|
</button>
|
|
</div>
|
|
<div class="workbar-group sheet-tools">
|
|
<div class="sheet-list" id="sheetList">
|
|
<select class="select sheet-select" aria-label="Datei oder Tabellenblatt auswählen" disabled>
|
|
<option>Keine Datei · 0</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="workbar-group workbar-action">
|
|
<div class="action-panel-row"><span>Markiert</span><b id="actionCount">0</b></div>
|
|
<label class="action-toggle"><input id="actionOnlyToggle" type="checkbox"> Nur Actionlist anzeigen</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<section class="viewer panel">
|
|
<div class="viewer-body" id="viewerBody">
|
|
<div class="empty-state">
|
|
<div>
|
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M4 3h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Zm1 4v3h4V7H5Zm6 0v3h8V7h-8Zm8 5h-8v5h8v-5Zm-10 5v-5H5v5h4Z"/></svg>
|
|
<div>Bereit für tabellarische Daten und verschachtelte Strukturen.</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</section>
|
|
</main>
|
|
<section class="results-floating-window" id="resultsPopup" role="dialog" aria-modal="false" aria-labelledby="resultsPopupTitle" hidden>
|
|
<header class="results-floating-head" id="resultsPopupHandle">
|
|
<div class="results-floating-title" id="resultsPopupTitle">Results</div>
|
|
<button class="results-floating-close" id="resultsPopupClose" type="button" aria-label="Results-Fenster schliessen" title="Schliessen">×</button>
|
|
</header>
|
|
<div class="results-floating-body" id="resultsPopupBody"></div>
|
|
</section>
|
|
<dialog class="project-import-dialog" id="projectImportDialog" aria-labelledby="projectImportTitle">
|
|
<div class="project-import-head"><h2 id="projectImportTitle">Scan-Projekt prüfen</h2></div>
|
|
<div class="project-import-body" id="projectImportBody"></div>
|
|
<div class="project-import-actions">
|
|
<button class="btn" id="projectImportCancel" type="button">Abbrechen</button>
|
|
<button class="btn primary" id="projectImportConfirm" type="button">Projekt laden</button>
|
|
</div>
|
|
</dialog>
|
|
</div>
|
|
|
|
<script>
|
|
const state = {
|
|
file: null,
|
|
format: '',
|
|
rawText: '',
|
|
sheets: [],
|
|
activeSheetIndex: 0,
|
|
view: 'columns',
|
|
search: '',
|
|
columnFilter: 'all',
|
|
purpleKnightFilter: 'all',
|
|
sortDirection: 'desc',
|
|
titleColumn: '',
|
|
actionOnly: false,
|
|
selectedRecordIndex: 0,
|
|
reviewStoreKey: '',
|
|
snapshotId: '',
|
|
projectFiles: [],
|
|
scanTool: '',
|
|
reviews: {}
|
|
};
|
|
|
|
const tileResultsWindows = new Map();
|
|
const tileResultsPalette = ['#7c3aed', '#0f766e', '#b45309', '#be123c', '#1d4ed8', '#6d28d9'];
|
|
let tileResultsColorIndex = 0;
|
|
let floatingResultsZ = 2100;
|
|
|
|
const themeStorageKey = 'exxTableReader.theme';
|
|
const systemThemeQuery = window.matchMedia('(prefers-color-scheme: light)');
|
|
let selectedThemeMode = 'dark';
|
|
|
|
function applyThemeMode(mode, persist = true) {
|
|
selectedThemeMode = ['dark', 'light', 'system'].includes(mode) ? mode : 'dark';
|
|
const effective = selectedThemeMode === 'system' ? (systemThemeQuery.matches ? 'light' : 'dark') : selectedThemeMode;
|
|
document.documentElement.dataset.theme = effective;
|
|
document.documentElement.dataset.themeMode = selectedThemeMode;
|
|
document.querySelectorAll('[data-theme-mode]').forEach(button => {
|
|
const active = button.dataset.themeMode === selectedThemeMode;
|
|
button.classList.toggle('active', active);
|
|
button.setAttribute('aria-pressed', String(active));
|
|
});
|
|
if (persist) {
|
|
try { localStorage.setItem(themeStorageKey, selectedThemeMode); } catch {}
|
|
}
|
|
}
|
|
|
|
function initThemeControls() {
|
|
let stored = 'dark';
|
|
try { stored = localStorage.getItem(themeStorageKey) || 'dark'; } catch {}
|
|
document.querySelectorAll('[data-theme-mode]').forEach(button => {
|
|
button.addEventListener('click', () => applyThemeMode(button.dataset.themeMode));
|
|
});
|
|
systemThemeQuery.addEventListener?.('change', () => {
|
|
if (selectedThemeMode === 'system') applyThemeMode('system', false);
|
|
});
|
|
applyThemeMode(stored, false);
|
|
}
|
|
|
|
const el = {
|
|
fileInput: document.getElementById('fileInput'),
|
|
dropZone: document.getElementById('dropZone'),
|
|
pickBtn: document.getElementById('pickBtn'),
|
|
openBtn: document.getElementById('openBtn'),
|
|
snapshotBtn: document.getElementById('snapshotBtn'),
|
|
myWorkspaceBtn: document.getElementById('myWorkspaceBtn'),
|
|
visibleHeaderCount: document.getElementById('visibleHeaderCount'),
|
|
markedHeaderCount: document.getElementById('markedHeaderCount'),
|
|
copyBtn: document.getElementById('copyBtn'),
|
|
downloadBtn: document.getElementById('downloadBtn'),
|
|
delimiterSelect: document.getElementById('delimiterSelect'),
|
|
fileName: document.getElementById('fileName'),
|
|
fileType: document.getElementById('fileType'),
|
|
fileSize: document.getElementById('fileSize'),
|
|
fileStatus: document.getElementById('fileStatus'),
|
|
excelNotice: document.getElementById('excelNotice'),
|
|
searchInput: document.getElementById('searchInput'),
|
|
searchPreviousBtn: document.getElementById('searchPreviousBtn'),
|
|
searchNextBtn: document.getElementById('searchNextBtn'),
|
|
titleColumnSelect: document.getElementById('titleColumnSelect'),
|
|
columnFilterSelect: document.getElementById('columnFilterSelect'),
|
|
purpleKnightFilterSelect: document.getElementById('purpleKnightFilterSelect'),
|
|
sortDirectionBtn: document.getElementById('sortDirectionBtn'),
|
|
clearSearchBtn: document.getElementById('clearSearchBtn'),
|
|
sheetList: document.getElementById('sheetList'),
|
|
rowCount: document.getElementById('rowCount'),
|
|
columnCount: document.getElementById('columnCount'),
|
|
sheetCount: document.getElementById('sheetCount'),
|
|
formatLabel: document.getElementById('formatLabel'),
|
|
viewerTitle: document.getElementById('viewerTitle'),
|
|
viewerHint: document.getElementById('viewerHint'),
|
|
viewerBody: document.getElementById('viewerBody'),
|
|
actionCount: document.getElementById('actionCount'),
|
|
actionOnlyToggle: document.getElementById('actionOnlyToggle'),
|
|
sidebarToggle: document.getElementById('sidebarToggle'),
|
|
resultsPopup: document.getElementById('resultsPopup'),
|
|
resultsPopupHandle: document.getElementById('resultsPopupHandle'),
|
|
resultsPopupTitle: document.getElementById('resultsPopupTitle'),
|
|
resultsPopupBody: document.getElementById('resultsPopupBody'),
|
|
resultsPopupClose: document.getElementById('resultsPopupClose')
|
|
};
|
|
|
|
initThemeControls();
|
|
|
|
el.sidebarToggle.addEventListener('click', () => {
|
|
const collapsed = document.body.classList.toggle('sidebar-collapsed');
|
|
el.sidebarToggle.setAttribute('aria-label', collapsed ? 'Sidebar ausklappen' : 'Sidebar einklappen');
|
|
el.sidebarToggle.title = collapsed ? 'Sidebar ausklappen' : 'Sidebar einklappen';
|
|
});
|
|
|
|
el.resultsPopupClose.addEventListener('click', () => { el.resultsPopup.hidden = true; });
|
|
el.resultsPopup.addEventListener('pointerdown', () => { el.resultsPopup.style.zIndex = '2001'; });
|
|
let resultsPopupDrag = null;
|
|
el.resultsPopupHandle.addEventListener('pointerdown', event => {
|
|
if (event.target.closest('button')) return;
|
|
const rect = el.resultsPopup.getBoundingClientRect();
|
|
resultsPopupDrag = { pointerId: event.pointerId, offsetX: event.clientX - rect.left, offsetY: event.clientY - rect.top };
|
|
el.resultsPopupHandle.setPointerCapture(event.pointerId);
|
|
event.preventDefault();
|
|
});
|
|
el.resultsPopupHandle.addEventListener('pointermove', event => {
|
|
if (!resultsPopupDrag || resultsPopupDrag.pointerId !== event.pointerId) return;
|
|
const maxLeft = Math.max(0, window.innerWidth - el.resultsPopup.offsetWidth);
|
|
const maxTop = Math.max(0, window.innerHeight - el.resultsPopup.offsetHeight);
|
|
el.resultsPopup.style.left = Math.min(maxLeft, Math.max(0, event.clientX - resultsPopupDrag.offsetX)) + 'px';
|
|
el.resultsPopup.style.top = Math.min(maxTop, Math.max(0, event.clientY - resultsPopupDrag.offsetY)) + 'px';
|
|
});
|
|
const stopResultsPopupDrag = event => {
|
|
if (!resultsPopupDrag || resultsPopupDrag.pointerId !== event.pointerId) return;
|
|
resultsPopupDrag = null;
|
|
if (el.resultsPopupHandle.hasPointerCapture(event.pointerId)) el.resultsPopupHandle.releasePointerCapture(event.pointerId);
|
|
};
|
|
el.resultsPopupHandle.addEventListener('pointerup', stopResultsPopupDrag);
|
|
el.resultsPopupHandle.addEventListener('pointercancel', stopResultsPopupDrag);
|
|
|
|
el.pickBtn.addEventListener('click', () => el.fileInput.click());
|
|
el.openBtn.addEventListener('click', () => el.fileInput.click());
|
|
el.snapshotBtn.addEventListener('click', createSnapshot);
|
|
el.myWorkspaceBtn.addEventListener('click', () => {
|
|
if (!actionRecords().length) return;
|
|
state.actionOnly = !state.actionOnly;
|
|
state.view = 'columns';
|
|
document.querySelectorAll('.tab-btn').forEach(tab => tab.classList.toggle('active', tab.dataset.view === state.view));
|
|
render();
|
|
});
|
|
el.fileInput.addEventListener('change', event => {
|
|
const files = [...event.target.files];
|
|
if (files.length) loadFiles(files);
|
|
event.target.value = '';
|
|
});
|
|
|
|
['dragenter', 'dragover'].forEach(type => {
|
|
el.dropZone.addEventListener(type, event => {
|
|
event.preventDefault();
|
|
el.dropZone.classList.add('dragover');
|
|
});
|
|
});
|
|
|
|
['dragleave', 'drop'].forEach(type => {
|
|
el.dropZone.addEventListener(type, event => {
|
|
event.preventDefault();
|
|
el.dropZone.classList.remove('dragover');
|
|
});
|
|
});
|
|
|
|
el.dropZone.addEventListener('drop', event => {
|
|
const files = [...event.dataTransfer.files];
|
|
if (files.length) loadFiles(files);
|
|
});
|
|
|
|
document.querySelectorAll('.tab-btn').forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
state.view = button.dataset.view;
|
|
document.querySelectorAll('.tab-btn').forEach(tab => tab.classList.toggle('active', tab === button));
|
|
render();
|
|
});
|
|
});
|
|
|
|
el.searchInput.addEventListener('input', () => {
|
|
state.search = el.searchInput.value.trim().toLowerCase();
|
|
state.selectedRecordIndex = 0;
|
|
render();
|
|
});
|
|
el.searchInput.addEventListener('keydown', event => {
|
|
if (event.key !== 'Enter') return;
|
|
event.preventDefault();
|
|
navigateSearchResult(event.shiftKey ? -1 : 1);
|
|
});
|
|
|
|
el.searchPreviousBtn.addEventListener('click', () => navigateSearchResult(-1));
|
|
el.searchNextBtn.addEventListener('click', () => navigateSearchResult(1));
|
|
|
|
el.titleColumnSelect.addEventListener('change', () => {
|
|
state.titleColumn = el.titleColumnSelect.value;
|
|
render();
|
|
});
|
|
|
|
el.columnFilterSelect.addEventListener('change', () => {
|
|
state.columnFilter = el.columnFilterSelect.value;
|
|
state.selectedRecordIndex = 0;
|
|
render();
|
|
});
|
|
|
|
el.purpleKnightFilterSelect.addEventListener('change', () => {
|
|
state.purpleKnightFilter = el.purpleKnightFilterSelect.value;
|
|
state.selectedRecordIndex = 0;
|
|
render();
|
|
});
|
|
el.sortDirectionBtn.addEventListener('click', () => {
|
|
state.sortDirection = state.sortDirection === 'asc' ? 'desc' : 'asc';
|
|
state.selectedRecordIndex = 0;
|
|
render();
|
|
});
|
|
|
|
el.clearSearchBtn.addEventListener('click', () => {
|
|
el.searchInput.value = '';
|
|
state.search = '';
|
|
state.columnFilter = 'all';
|
|
state.purpleKnightFilter = 'all';
|
|
el.columnFilterSelect.value = 'all';
|
|
el.purpleKnightFilterSelect.value = 'all';
|
|
state.selectedRecordIndex = 0;
|
|
render();
|
|
});
|
|
|
|
el.actionOnlyToggle.addEventListener('change', () => {
|
|
state.actionOnly = el.actionOnlyToggle.checked;
|
|
state.selectedRecordIndex = 0;
|
|
render();
|
|
});
|
|
|
|
document.querySelectorAll('[data-action-export]').forEach(button => {
|
|
button.addEventListener('click', () => exportActionList(button.dataset.actionExport));
|
|
});
|
|
|
|
el.delimiterSelect.addEventListener('change', () => {
|
|
if (state.file && state.format === 'CSV') parseCsvState();
|
|
});
|
|
|
|
el.copyBtn.addEventListener('click', async () => {
|
|
const sheet = activeSheet();
|
|
const payload = state.view === 'table' && sheet ? toCsv(sheet.rows) : visibleText();
|
|
if (!payload) return;
|
|
await navigator.clipboard.writeText(payload);
|
|
setStatus('Kopiert');
|
|
});
|
|
|
|
el.downloadBtn.addEventListener('click', () => {
|
|
const sheet = activeSheet();
|
|
if (!sheet) return;
|
|
const blob = new Blob([toCsv(sheet.rows)], { type: 'text/csv;charset=utf-8' });
|
|
const link = document.createElement('a');
|
|
link.href = URL.createObjectURL(blob);
|
|
link.download = `${baseName(state.file?.name || 'export')}-${sheet.name}.csv`;
|
|
link.click();
|
|
URL.revokeObjectURL(link.href);
|
|
});
|
|
|
|
async function createSnapshot() {
|
|
if (!state.sheets.length) {
|
|
setStatus('Keine Daten für Snapshot');
|
|
return;
|
|
}
|
|
const snapshot = buildSnapshot();
|
|
const filename = snapshotFileName(snapshot);
|
|
const html = buildSnapshotDocument(snapshot);
|
|
await saveTextAs(filename, html, 'text/html;charset=utf-8', 'Snapshot erstellt');
|
|
}
|
|
|
|
function snapshotFileName(snapshot) {
|
|
const original = baseName(snapshot.sourceFile?.name || state.file?.name || 'daten') || 'daten';
|
|
return `${sanitizeFileName(original)}-Kopie-${formatSnapshotDate(snapshot.createdAt)}.html`;
|
|
}
|
|
|
|
function formatSnapshotDate(value) {
|
|
const date = value ? new Date(value) : new Date();
|
|
const pad = number => String(number).padStart(2, '0');
|
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`;
|
|
}
|
|
|
|
function sanitizeFileName(value) {
|
|
return String(value).trim().replace(/[<>:"/\\|?*\x00-\x1F]/g, '_').replace(/\s+/g, ' ').replace(/[. ]+$/g, '') || 'daten';
|
|
}
|
|
function buildSnapshot() {
|
|
const id = newSnapshotId();
|
|
return {
|
|
app: 'exxTableReader',
|
|
kind: 'snapshot',
|
|
version: 1,
|
|
id,
|
|
createdAt: new Date().toISOString(),
|
|
sourceFile: {
|
|
name: state.file?.name || 'Daten',
|
|
type: state.file?.type || state.format || '',
|
|
size: state.file?.size || 0,
|
|
lastModified: state.file?.lastModified || 0
|
|
},
|
|
format: state.format,
|
|
rawText: state.rawText,
|
|
activeSheetIndex: state.activeSheetIndex,
|
|
view: state.view,
|
|
search: state.search,
|
|
columnFilter: state.columnFilter,
|
|
purpleKnightFilter: state.purpleKnightFilter,
|
|
sortDirection: state.sortDirection,
|
|
titleColumn: state.titleColumn,
|
|
projectFiles: JSON.parse(JSON.stringify(state.projectFiles || [])),
|
|
scanTool: state.scanTool || '',
|
|
actionOnly: state.actionOnly,
|
|
selectedRecordIndex: state.selectedRecordIndex,
|
|
sheets: state.sheets.map(sheet => ({
|
|
name: sheet.name,
|
|
rows: sheet.rows,
|
|
tree: sheet.tree,
|
|
raw: sheet.raw
|
|
})),
|
|
reviews: JSON.parse(JSON.stringify(state.reviews || {}))
|
|
};
|
|
}
|
|
|
|
function buildSnapshotDocument(snapshot) {
|
|
const clone = document.documentElement.cloneNode(true);
|
|
const oldSnapshot = clone.querySelector('#embeddedSnapshot');
|
|
if (oldSnapshot) oldSnapshot.remove();
|
|
const dataScript = document.createElement('script');
|
|
dataScript.type = 'application/json';
|
|
dataScript.id = 'embeddedSnapshot';
|
|
dataScript.textContent = JSON.stringify(snapshot).replace(/<\/script/gi, '<\\/script');
|
|
clone.querySelector('body').insertBefore(dataScript, clone.querySelector('body script'));
|
|
return '<!doctype html>\n' + clone.outerHTML;
|
|
}
|
|
|
|
function parseSnapshotPayload(text) {
|
|
try {
|
|
const value = JSON.parse(text);
|
|
return value?.app === 'exxTableReader' && value?.kind === 'snapshot' && Array.isArray(value.sheets) ? value : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function loadSnapshot(snapshot, file) {
|
|
state.snapshotId = snapshot.id || newSnapshotId();
|
|
state.file = {
|
|
name: file?.name || `${baseName(snapshot.sourceFile?.name || 'daten')}-snapshot.exx-snapshot.json`,
|
|
type: 'Snapshot',
|
|
size: file?.size || 0,
|
|
lastModified: file?.lastModified || Date.parse(snapshot.createdAt) || Date.now(),
|
|
snapshotId: state.snapshotId
|
|
};
|
|
state.format = snapshot.format || 'Snapshot';
|
|
state.rawText = snapshot.rawText || '';
|
|
state.sheets = snapshot.sheets.map(sheet => makeSheet(sheet.name || 'Snapshot', sheet.rows || [], sheet.tree || sheet.rows || [], sheet.raw || ''));
|
|
state.activeSheetIndex = Math.min(snapshot.activeSheetIndex || 0, Math.max(state.sheets.length - 1, 0));
|
|
state.view = ['records', 'columns', 'table', 'raw'].includes(snapshot.view) ? snapshot.view : 'records';
|
|
state.search = snapshot.search || '';
|
|
const legacyPurpleKnightFilter = typeof snapshot.columnFilter === 'string' && snapshot.columnFilter.startsWith('pk-status:') ? snapshot.columnFilter.slice('pk-status:'.length) : '';
|
|
state.columnFilter = legacyPurpleKnightFilter ? 'all' : snapshot.columnFilter || 'all';
|
|
state.purpleKnightFilter = snapshot.purpleKnightFilter || legacyPurpleKnightFilter || 'all';
|
|
state.sortDirection = snapshot.sortDirection === 'asc' ? 'asc' : 'desc';
|
|
state.titleColumn = snapshot.titleColumn || '';
|
|
state.projectFiles = Array.isArray(snapshot.projectFiles) ? snapshot.projectFiles : [];
|
|
state.scanTool = snapshot.scanTool || '';
|
|
state.actionOnly = Boolean(snapshot.actionOnly);
|
|
state.selectedRecordIndex = snapshot.selectedRecordIndex || 0;
|
|
state.reviewStoreKey = reviewStorageKey(state.file);
|
|
state.reviews = JSON.parse(JSON.stringify(snapshot.reviews || {}));
|
|
saveReviews();
|
|
el.searchInput.value = state.search;
|
|
el.actionOnlyToggle.checked = state.actionOnly;
|
|
document.querySelectorAll('.tab-btn').forEach(tab => tab.classList.toggle('active', tab.dataset.view === state.view));
|
|
}
|
|
|
|
function newSnapshotId() {
|
|
if (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') return globalThis.crypto.randomUUID();
|
|
return `snapshot-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
}
|
|
async function loadFiles(files) {
|
|
const selectedFiles = [...files].filter(Boolean);
|
|
if (!selectedFiles.length) return;
|
|
if (selectedFiles.length === 1) {
|
|
await loadFile(selectedFiles[0]);
|
|
return;
|
|
}
|
|
|
|
const previousState = { ...state };
|
|
try {
|
|
setStatus(`Lade Projekt 0/${selectedFiles.length}...`);
|
|
const sources = [];
|
|
for (let index = 0; index < selectedFiles.length; index++) {
|
|
const file = selectedFiles[index];
|
|
setStatus(`Lade Projekt ${index + 1}/${selectedFiles.length}...`);
|
|
const loaded = await loadFile(file, { silent: true });
|
|
if (!loaded || !state.sheets.length) throw new Error(`${file.name} konnte nicht als Tabelle gelesen werden.`);
|
|
sources.push({
|
|
file: { name: file.name, size: file.size || 0, lastModified: file.lastModified || 0, type: file.type || '' },
|
|
format: state.format,
|
|
rawText: state.rawText,
|
|
sheets: state.sheets,
|
|
tool: detectScanTool(file.name, state.sheets, state.rawText)
|
|
});
|
|
}
|
|
|
|
const detectedTools = [...new Set(sources.map(source => source.tool).filter(tool => tool !== 'Unbekannt'))];
|
|
if (detectedTools.length > 1) {
|
|
throw new Error(`Gemischte Scan-Werkzeuge sind nicht erlaubt: ${detectedTools.join(', ')}.`);
|
|
}
|
|
const projectTool = detectedTools[0] || 'Tabellenprojekt';
|
|
const confirmed = await showProjectImportPreview(sources, projectTool);
|
|
if (!confirmed) {
|
|
Object.assign(state, previousState);
|
|
el.searchInput.value = state.search || '';
|
|
el.actionOnlyToggle.checked = Boolean(state.actionOnly);
|
|
updateMeta();
|
|
render();
|
|
setStatus('Projektimport abgebrochen');
|
|
return;
|
|
}
|
|
finalizeScanProject(sources, projectTool);
|
|
} catch (error) {
|
|
console.error(error);
|
|
Object.assign(state, previousState);
|
|
el.searchInput.value = state.search || '';
|
|
el.actionOnlyToggle.checked = Boolean(state.actionOnly);
|
|
updateMeta();
|
|
render();
|
|
setStatus(`Import abgebrochen: ${error.message || 'Dateien konnten nicht verbunden werden.'}`);
|
|
}
|
|
}
|
|
|
|
function showProjectImportPreview(sources, tool) {
|
|
const dialog = document.getElementById('projectImportDialog');
|
|
const body = document.getElementById('projectImportBody');
|
|
const confirmButton = document.getElementById('projectImportConfirm');
|
|
const cancelButton = document.getElementById('projectImportCancel');
|
|
if (!dialog || typeof dialog.showModal !== 'function') return Promise.resolve(true);
|
|
|
|
const totalRecords = sources.reduce((sum, source) => sum + source.sheets.reduce((sheetSum, sheet) => sheetSum + sheet.analysis.records.length, 0), 0);
|
|
const headerFrequency = new Map();
|
|
sources.forEach(source => {
|
|
const sourceHeaders = new Set(source.sheets.flatMap(sheet => sheet.analysis.headers));
|
|
sourceHeaders.forEach(header => headerFrequency.set(header, (headerFrequency.get(header) || 0) + 1));
|
|
});
|
|
const sharedHeaders = [...headerFrequency.values()].filter(count => count > 1).length;
|
|
const signatures = new Set();
|
|
let duplicates = 0;
|
|
sources.forEach(source => source.sheets.forEach(sheet => sheet.analysis.records.forEach(record => {
|
|
const signature = JSON.stringify(record.fields.filter(field => field.value).map(field => [field.label, field.value]).sort(([left], [right]) => left.localeCompare(right)));
|
|
if (!signature || signature === '[]') return;
|
|
if (signatures.has(signature)) duplicates += 1;
|
|
else signatures.add(signature);
|
|
})));
|
|
const unknownSources = sources.filter(source => source.tool === 'Unbekannt');
|
|
body.innerHTML = `
|
|
<p class="project-import-summary"><strong>${escapeHtml(tool)}</strong> · ${sources.length.toLocaleString('de-DE')} Dateien · ${totalRecords.toLocaleString('de-DE')} Datensätze · ${sharedHeaders.toLocaleString('de-DE')} gemeinsame Spalten · ${duplicates.toLocaleString('de-DE')} mögliche Duplikate</p>
|
|
<table class="project-import-table">
|
|
<thead><tr><th>Datei</th><th>Format</th><th>Erkennung</th><th>Blätter</th><th>Datensätze</th></tr></thead>
|
|
<tbody>${sources.map(source => `<tr><td>${escapeHtml(source.file.name)}</td><td>${escapeHtml(source.format)}</td><td>${escapeHtml(source.tool)}</td><td>${source.sheets.length}</td><td>${source.sheets.reduce((sum, sheet) => sum + sheet.analysis.records.length, 0).toLocaleString('de-DE')}</td></tr>`).join('')}</tbody>
|
|
</table>
|
|
${unknownSources.length ? `<div class="project-import-warning">${unknownSources.length} Datei(en) konnten keinem Werkzeug eindeutig zugeordnet werden. Sie werden dem erkannten Projekt zugeordnet; die Herkunft bleibt sichtbar.</div>` : ''}`;
|
|
|
|
return new Promise(resolve => {
|
|
let settled = false;
|
|
const finish = accepted => {
|
|
if (settled) return;
|
|
settled = true;
|
|
confirmButton.removeEventListener('click', confirmHandler);
|
|
cancelButton.removeEventListener('click', cancelHandler);
|
|
dialog.removeEventListener('cancel', dialogCancelHandler);
|
|
if (dialog.open) dialog.close();
|
|
resolve(accepted);
|
|
};
|
|
const confirmHandler = () => finish(true);
|
|
const cancelHandler = () => finish(false);
|
|
const dialogCancelHandler = event => { event.preventDefault(); finish(false); };
|
|
confirmButton.addEventListener('click', confirmHandler);
|
|
cancelButton.addEventListener('click', cancelHandler);
|
|
dialog.addEventListener('cancel', dialogCancelHandler);
|
|
dialog.showModal();
|
|
});
|
|
}
|
|
function finalizeScanProject(sources, tool) {
|
|
const projectSheet = buildProjectSheet(sources, tool);
|
|
const sourceSheets = sources.flatMap(source => source.sheets.map(sheet => {
|
|
const sourceName = sheet.name;
|
|
const projectName = `${baseName(source.file.name)} · ${sourceName}`;
|
|
sheet.analysis.records.forEach(record => {
|
|
record.sourceFile = source.file.name;
|
|
record.sourceSheet = sourceName;
|
|
});
|
|
return { ...sheet, name: projectName, sourceFile: source.file.name, sourceSheet: sourceName };
|
|
}));
|
|
const totalSize = sources.reduce((sum, source) => sum + source.file.size, 0);
|
|
const lastModified = Math.max(...sources.map(source => source.file.lastModified || 0), 0);
|
|
state.scanTool = tool;
|
|
state.projectFiles = sources.map(source => ({ ...source.file, format: source.format, tool: source.tool }));
|
|
state.file = {
|
|
name: `${tool} · ${sources.length} Dateien`,
|
|
type: 'Scan-Projekt',
|
|
size: totalSize,
|
|
lastModified
|
|
};
|
|
state.format = `Projekt · ${tool}`;
|
|
state.rawText = sources.map(source => `# Datei: ${source.file.name}\n${source.rawText}`).join('\n\n');
|
|
state.sheets = projectSheet.analysis.records.length ? [projectSheet, ...sourceSheets] : sourceSheets;
|
|
state.activeSheetIndex = 0;
|
|
state.selectedRecordIndex = 0;
|
|
state.search = '';
|
|
state.columnFilter = 'all';
|
|
state.purpleKnightFilter = 'all';
|
|
state.sortDirection = 'desc';
|
|
state.titleColumn = '';
|
|
applyScanToolDefaults(tool, state.sheets[0]);
|
|
state.actionOnly = false;
|
|
state.snapshotId = '';
|
|
state.reviewStoreKey = reviewStorageKey(state.file);
|
|
state.reviews = loadReviews();
|
|
el.searchInput.value = '';
|
|
el.actionOnlyToggle.checked = false;
|
|
el.columnFilterSelect.innerHTML = '<option value="all">Keine numerische Sortierung</option>';
|
|
el.titleColumnSelect.innerHTML = '<option value="">Titel (auto)</option>';
|
|
updateMeta();
|
|
render();
|
|
setStatus(`${sources.length} Dateien als ${tool}-Projekt geladen`);
|
|
}
|
|
|
|
function detectScanTool(fileName, sheets, rawText) {
|
|
const headers = sheets.flatMap(sheet => sheet.analysis.headers).map(header => clean(header).toLowerCase());
|
|
const structuralText = sheets.flatMap(sheet => [sheet.name, ...sheet.analysis.headers]).join(' ');
|
|
const sheetNames = sheets.map(sheet => clean(sheet.name).toLowerCase());
|
|
const hasIndicatorResultsSheet = sheetNames.some(name => /\bindicator(?:s)?\s+results?\b/i.test(name));
|
|
const hasAssessmentSheet = sheetNames.some(name => /\bassessment\b/i.test(name));
|
|
const haystack = `${fileName} ${structuralText} ${String(rawText || '').slice(0, 250000)}`.toLowerCase();
|
|
const normalizedName = String(fileName || '').toLowerCase().replace(/[^a-z0-9äöüß]+/g, '_');
|
|
const hasHeader = pattern => headers.some(header => pattern.test(header));
|
|
const purpleKnightSchemaSignals = [
|
|
hasHeader(/^short\s*name$/i),
|
|
hasHeader(/^si\s*version$/i),
|
|
hasHeader(/^severity$/i),
|
|
hasHeader(/^weight$/i),
|
|
hasHeader(/^category$/i),
|
|
hasHeader(/mitre\s*att&?ck/i),
|
|
hasHeader(/likelihood\s+of\s+compromise/i),
|
|
hasHeader(/^target$/i) && hasHeader(/^score$/i)
|
|
].filter(Boolean).length;
|
|
|
|
if (
|
|
(hasIndicatorResultsSheet && hasAssessmentSheet) ||
|
|
/purple[\s_-]*knight/.test(haystack) ||
|
|
/security_assessment_report/.test(normalizedName) ||
|
|
/indicators?_results?/.test(normalizedName) ||
|
|
purpleKnightSchemaSignals >= 3
|
|
) return 'PurpleKnight';
|
|
if (/ping[\s_-]*castle/.test(haystack)) return 'PingCastle';
|
|
if (/cis[\s_-]*cat(?:\s+pro)?|ciscat/.test(haystack)) return 'CIS-CAT Pro';
|
|
return 'Unbekannt';
|
|
}
|
|
|
|
function applyScanToolDefaults(tool, sheet) {
|
|
if (tool !== 'PurpleKnight' || !sheet) return;
|
|
const headerMatching = pattern => sheet.analysis.headers.find(header => pattern.test(clean(header)));
|
|
const nameHeader = headerMatching(/^(?:name|indicator\s+name|security\s+indicator|finding|title)$/i);
|
|
const weightHeader = headerMatching(/^weight$/i);
|
|
if (nameHeader) state.titleColumn = nameHeader;
|
|
if (weightHeader && numericColumnIndexes(sheet).includes(sheet.analysis.headers.indexOf(weightHeader))) {
|
|
state.columnFilter = weightHeader;
|
|
}
|
|
state.purpleKnightFilter = 'ioes-found';
|
|
state.sortDirection = 'desc';
|
|
}
|
|
function buildProjectSheet(sources, tool) {
|
|
const provenanceHeaders = ['Quelle: Datei', 'Quelle: Blatt'];
|
|
const headers = [];
|
|
const seenHeaders = new Set(provenanceHeaders);
|
|
sources.forEach(source => source.sheets.forEach(sheet => sheet.analysis.headers.forEach(header => {
|
|
if (!seenHeaders.has(header)) {
|
|
seenHeaders.add(header);
|
|
headers.push(header);
|
|
}
|
|
})));
|
|
const rows = [[...provenanceHeaders, ...headers]];
|
|
sources.forEach(source => source.sheets.forEach(sheet => {
|
|
sheet.analysis.records.forEach(record => {
|
|
const values = new Map(record.fields.map(field => [field.label, field.value]));
|
|
rows.push([source.file.name, sheet.name, ...headers.map(header => values.get(header) || '')]);
|
|
});
|
|
}));
|
|
const sheet = makeSheet(`${tool} · Alle Datensätze`, rows, rows, toCsv(rows));
|
|
sheet.isProjectSheet = true;
|
|
return sheet;
|
|
}
|
|
|
|
async function loadFile(file, { silent = false } = {}) {
|
|
resetState(file, { silent: true });
|
|
if (!silent) setStatus('Lade...');
|
|
try {
|
|
const extension = file.name.split('.').pop().toLowerCase();
|
|
if (['xlsx', 'xls'].includes(extension)) {
|
|
await parseExcel(file);
|
|
} else {
|
|
const buf = await file.arrayBuffer();
|
|
try {
|
|
state.rawText = new TextDecoder('utf-8', { fatal: true }).decode(buf);
|
|
} catch (e) {
|
|
state.rawText = new TextDecoder('windows-1252').decode(buf);
|
|
}
|
|
if (extension === 'json') {
|
|
const snapshot = parseSnapshotPayload(state.rawText);
|
|
if (snapshot) loadSnapshot(snapshot, file);
|
|
else parseJson();
|
|
} else if (extension === 'xml') parseXml();
|
|
else if (['html', 'htm'].includes(extension)) parseHtmlFile();
|
|
else parseCsvState(false);
|
|
}
|
|
if (!silent) {
|
|
if (!state.snapshotId) {
|
|
state.scanTool = detectScanTool(file.name, state.sheets, state.rawText);
|
|
applyScanToolDefaults(state.scanTool, state.sheets[0]);
|
|
}
|
|
setStatus('Geladen');
|
|
updateMeta();
|
|
render();
|
|
}
|
|
return true;
|
|
} catch (error) {
|
|
console.error(error);
|
|
if (!silent) {
|
|
setStatus('Fehler');
|
|
el.viewerBody.innerHTML = `<div class="empty-state"><div>${escapeHtml(error.message || 'Datei konnte nicht gelesen werden.')}</div></div>`;
|
|
updateMeta();
|
|
render();
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function resetState(file, { silent = false } = {}) {
|
|
state.file = file;
|
|
state.format = '';
|
|
state.rawText = '';
|
|
state.sheets = [];
|
|
state.activeSheetIndex = 0;
|
|
state.selectedRecordIndex = 0;
|
|
state.search = '';
|
|
state.columnFilter = 'all';
|
|
state.purpleKnightFilter = 'all';
|
|
state.sortDirection = 'desc';
|
|
state.titleColumn = '';
|
|
state.actionOnly = false;
|
|
state.reviewStoreKey = reviewStorageKey(file);
|
|
state.snapshotId = '';
|
|
state.projectFiles = [];
|
|
state.scanTool = '';
|
|
state.reviews = loadReviews();
|
|
el.searchInput.value = '';
|
|
el.columnFilterSelect.innerHTML = '<option value="all">Keine numerische Sortierung</option>';
|
|
el.titleColumnSelect.innerHTML = '<option value="">Titel (auto)</option>';
|
|
el.actionOnlyToggle.checked = false;
|
|
if (!silent) updateMeta();
|
|
}
|
|
|
|
async function parseExcel(file) {
|
|
state.format = 'Excel';
|
|
el.excelNotice.hidden = Boolean(window.XLSX);
|
|
if (!window.XLSX) {
|
|
throw new Error('Excel-Bibliothek konnte nicht geladen werden. Bitte online erneut öffnen oder CSV/JSON/XML verwenden.');
|
|
}
|
|
const buffer = await file.arrayBuffer();
|
|
const workbook = XLSX.read(buffer, { type: 'array', cellDates: true, cellFormula: false });
|
|
const sheetVisibility = new Map((workbook.Workbook?.Sheets || []).map(sheet => [sheet.name, Number(sheet.Hidden || 0)]));
|
|
state.sheets = workbook.SheetNames.map((name, workbookIndex) => {
|
|
const worksheet = workbook.Sheets[name];
|
|
const rows = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: '', raw: false, blankrows: false, dateNF: 'yyyy-mm-dd' });
|
|
return {
|
|
...makeSheet(name, rows, rows, toCsv(normalizeRows(rows))),
|
|
workbookIndex,
|
|
hidden: sheetVisibility.get(name) || 0
|
|
};
|
|
});
|
|
state.rawText = state.sheets.map(sheet => `# ${sheet.name}\n${sheet.raw}`).join('\n\n');
|
|
|
|
state.sheets = state.sheets.filter(sheet => sheet.rows.some(row => row.some(cell => clean(cell))));
|
|
if (!state.sheets.length) throw new Error('Die Excel-Datei enth\u00e4lt keine auswertbaren Tabellenbl\u00e4tter.');
|
|
if (detectScanTool(file.name, state.sheets, state.rawText) === 'PurpleKnight') {
|
|
state.sheets = prioritizePurpleKnightSheets(state.sheets);
|
|
state.activeSheetIndex = 0;
|
|
state.format = 'Excel \u00b7 PurpleKnight';
|
|
}
|
|
}
|
|
|
|
function prioritizePurpleKnightSheets(sheets) {
|
|
return [...sheets].sort((left, right) => {
|
|
const scoreDifference = purpleKnightSheetScore(right) - purpleKnightSheetScore(left);
|
|
return scoreDifference || left.hidden - right.hidden || left.workbookIndex - right.workbookIndex;
|
|
});
|
|
}
|
|
|
|
function purpleKnightSheetScore(sheet) {
|
|
const name = clean(sheet.name).toLowerCase();
|
|
const headers = sheet.analysis.headers.map(header => clean(header).toLowerCase());
|
|
const sampleCells = sheet.rows.slice(0, 40).flat().map(cell => clean(cell).toLowerCase()).filter(Boolean);
|
|
const vocabulary = new Set([...headers, ...sampleCells]);
|
|
const has = pattern => [...vocabulary].some(value => pattern.test(value));
|
|
let score = sheet.hidden ? -100 : 0;
|
|
if (/^indicator(?:s)?\s+results?$/i.test(name)) score += 1000;
|
|
else if (/indicator(?:s)?\s+results?/i.test(name)) score += 700;
|
|
else if (/indicators?|findings?|ioe/i.test(name)) score += 250;
|
|
if (/assessment|summary|overview|dashboard/i.test(name)) score -= 120;
|
|
if (has(/^(?:name|indicator\s+name|security\s+indicator)$/i)) score += 80;
|
|
if (has(/^(?:result|status|evaluation)$/i)) score += 70;
|
|
if (has(/^severity$/i)) score += 60;
|
|
if (has(/^weight$/i)) score += 50;
|
|
if (has(/^category$/i)) score += 40;
|
|
if (has(/ioes?\s+found/i)) score += 40;
|
|
score += Math.min(sheet.analysis.records.length, 500) / 10;
|
|
return score;
|
|
}
|
|
function parseCsvState(renderNow = true) {
|
|
state.format = 'CSV';
|
|
const rows = parseCsv(state.rawText, selectedDelimiter(state.rawText));
|
|
state.sheets = [makeSheet('CSV', rows, rows, state.rawText)];
|
|
if (renderNow) {
|
|
updateMeta();
|
|
render();
|
|
}
|
|
}
|
|
|
|
function parseJson() {
|
|
state.format = 'JSON';
|
|
const parsed = JSON.parse(state.rawText);
|
|
const rows = jsonToRows(parsed);
|
|
state.sheets = [makeSheet(Array.isArray(parsed) ? 'Array' : 'Objekt', rows, parsed, formatJson(parsed))];
|
|
}
|
|
|
|
function parseXml() {
|
|
state.format = 'XML';
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(state.rawText, 'application/xml');
|
|
const parseError = doc.querySelector('parsererror');
|
|
if (parseError) throw new Error('XML konnte nicht geparst werden.');
|
|
const tree = xmlNodeToObject(doc.documentElement);
|
|
const rows = xmlToRows(doc.documentElement);
|
|
state.sheets = [makeSheet(doc.documentElement.nodeName, rows, tree, state.rawText)];
|
|
}
|
|
|
|
function parseHtmlFile() {
|
|
state.format = 'HTML';
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(state.rawText, 'text/html');
|
|
|
|
// Extract all <table> elements as separate sheets
|
|
const tables = [...doc.querySelectorAll('table')];
|
|
if (tables.length > 0) {
|
|
state.sheets = tables.map((table, index) => {
|
|
const caption = table.querySelector('caption')?.textContent.trim()
|
|
|| table.id
|
|
|| table.getAttribute('aria-label')
|
|
|| `Tabelle ${index + 1}`;
|
|
const rows = [];
|
|
[...table.querySelectorAll('tr')].forEach(tr => {
|
|
const cells = [...tr.querySelectorAll('th, td')].map(cell => cell.innerText ?? cell.textContent);
|
|
if (cells.length) rows.push(cells);
|
|
});
|
|
return makeSheet(caption, rows, rows, toCsv(normalizeRows(rows)));
|
|
});
|
|
state.rawText = state.sheets.map(sheet => `# ${sheet.name}\n${sheet.raw}`).join('\n\n');
|
|
} else {
|
|
// No tables found – treat as plain text (body text content)
|
|
const bodyText = doc.body?.innerText ?? doc.body?.textContent ?? state.rawText;
|
|
const rows = bodyText.split('\n').filter(line => line.trim()).map(line => [line.trim()]);
|
|
state.sheets = [makeSheet('HTML', rows, rows, state.rawText)];
|
|
}
|
|
}
|
|
|
|
function makeSheet(name, rows, tree, raw) {
|
|
const normalized = normalizeRows(rows);
|
|
return { name, rows: normalized, tree, raw, analysis: analyzeRows(normalized) };
|
|
}
|
|
|
|
function analyzeRows(rows) {
|
|
const compactRows = rows.filter(row => row.some(cell => clean(cell)));
|
|
if (!compactRows.length) {
|
|
return { headerIndex: 0, headers: [], records: [], columns: [], signals: ['Keine auswertbaren Inhalte gefunden.'], title: '' };
|
|
}
|
|
const headerIndex = detectHeaderRow(compactRows);
|
|
const title = compactRows.slice(0, headerIndex).flat().map(clean).filter(Boolean).join(' · ');
|
|
const headers = uniqueHeaders(compactRows[headerIndex] || []);
|
|
const dataRows = compactRows.slice(headerIndex + 1).filter(row => row.some(clean));
|
|
const records = dataRows.map((row, index) => rowToRecord(row, headers, index));
|
|
const columns = headers.map((header, columnIndex) => profileColumn(header, dataRows.map(row => clean(row[columnIndex]))));
|
|
return { headerIndex, headers, records, columns, signals: buildSignals(headerIndex, headers, records, columns, title), title };
|
|
}
|
|
|
|
function detectHeaderRow(rows) {
|
|
let best = { index: 0, score: -Infinity };
|
|
rows.slice(0, 12).forEach((row, index) => {
|
|
const values = row.map(clean).filter(Boolean);
|
|
const nextRows = rows.slice(index + 1, index + 5);
|
|
const nextFill = nextRows.reduce((sum, next) => sum + next.filter(clean).length, 0);
|
|
const uniqueCount = new Set(values.map(value => value.toLowerCase())).size;
|
|
const labelScore = values.filter(value => /[A-Za-zÄÖÜäöüß]/.test(value) && value.length <= 42).length;
|
|
const titlePenalty = values.some(value => value.length > 55 || value.includes('\n')) ? 4 : 0;
|
|
const datePenalty = values.filter(looksLikeDate).length * 1.5;
|
|
const score = values.length * 2 + uniqueCount + labelScore + nextFill * .35 - titlePenalty - datePenalty - index * .25;
|
|
if (score > best.score) best = { index, score };
|
|
});
|
|
return best.index;
|
|
}
|
|
|
|
function uniqueHeaders(row) {
|
|
const seen = new Map();
|
|
return row.map((cell, index) => {
|
|
const base = clean(cell) || `Feld ${index + 1}`;
|
|
const count = seen.get(base) || 0;
|
|
seen.set(base, count + 1);
|
|
return count ? `${base} (${count + 1})` : base;
|
|
});
|
|
}
|
|
|
|
function rowToRecord(row, headers, index) {
|
|
const fields = headers.map((header, columnIndex) => ({ label: header, value: clean(row[columnIndex]) }));
|
|
const filled = fields.filter(field => field.value);
|
|
return { index, fields, filled, title: recordTitle(filled, index), meta: recordMeta(filled) };
|
|
}
|
|
|
|
function recordTitle(fields, index) {
|
|
const preferred = ['tätigkeit', 'aktivität', 'aufgabe', 'name', 'titel', 'scope', 'ziel', 'beschreibung', 'tool'];
|
|
for (const token of preferred) {
|
|
const found = fields.find(field => field.label.toLowerCase().includes(token) && field.value);
|
|
if (found) return found.value;
|
|
}
|
|
return fields[0]?.value || `Datensatz ${index + 1}`;
|
|
}
|
|
|
|
function recordMeta(fields) {
|
|
const wanted = ['datum', 'status', 'zustand', 'norm', 'scope', 'priorität', 'risiko'];
|
|
return fields.filter(field => wanted.some(token => field.label.toLowerCase().includes(token)) && field.value).slice(0, 4);
|
|
}
|
|
|
|
function displayTitle(record) {
|
|
if (state.titleColumn) {
|
|
const match = record.fields.find(field => field.label === state.titleColumn);
|
|
if (match && match.value) return match.value;
|
|
}
|
|
return record.title;
|
|
}
|
|
|
|
function profileColumn(header, values) {
|
|
const filled = values.filter(Boolean);
|
|
const unique = new Set(filled.map(value => value.toLowerCase())).size;
|
|
const type = detectColumnType(filled);
|
|
const topValues = topCounts(filled).slice(0, 4);
|
|
return {
|
|
header,
|
|
type,
|
|
filled: filled.length,
|
|
total: values.length,
|
|
fillRate: values.length ? Math.round(filled.length / values.length * 100) : 0,
|
|
unique,
|
|
topValues
|
|
};
|
|
}
|
|
|
|
function detectColumnType(values) {
|
|
if (!values.length) return 'leer';
|
|
const sample = values.slice(0, 30);
|
|
if (sample.filter(looksLikeDate).length / sample.length > .65) return 'Datum';
|
|
if (sample.filter(value => /^-?\d+([,.]\d+)?$/.test(value)).length / sample.length > .75) return 'Zahl';
|
|
if (new Set(sample.map(value => value.toLowerCase())).size <= Math.max(5, sample.length * .35)) return 'Kategorie';
|
|
if (sample.some(value => value.length > 80)) return 'Text';
|
|
return 'Text kurz';
|
|
}
|
|
|
|
function buildSignals(headerIndex, headers, records, columns, title) {
|
|
const signals = [];
|
|
if (title) signals.push(`Titelbereich erkannt: ${title.slice(0, 120)}`);
|
|
if (headerIndex > 0) signals.push(`Die eigentliche Kopfzeile wurde in Zeile ${headerIndex + 1} erkannt.`);
|
|
const sparse = columns.filter(column => column.total && column.fillRate < 20).map(column => column.header).slice(0, 4);
|
|
if (sparse.length) signals.push(`Sehr leere Felder: ${sparse.join(', ')}.`);
|
|
const categorical = columns.filter(column => column.type === 'Kategorie' && column.topValues.length > 1).slice(0, 3);
|
|
if (categorical.length) signals.push(`Sinnvolle Gruppierungen gefunden: ${categorical.map(column => column.header).join(', ')}.`);
|
|
if (!records.length) signals.push('Nur Struktur erkannt, aber keine Datensätze unterhalb der Kopfzeile.');
|
|
return signals.length ? signals : ['Struktur wirkt konsistent: Kopfzeile, Datensätze und Felder konnten abgeleitet werden.'];
|
|
}
|
|
|
|
function topCounts(values) {
|
|
const counts = new Map();
|
|
values.forEach(value => counts.set(value, (counts.get(value) || 0) + 1));
|
|
return [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([value, count]) => ({ value, count }));
|
|
}
|
|
|
|
function parseCsv(text, delimiter) {
|
|
const rows = [];
|
|
let row = [];
|
|
let cell = '';
|
|
let quoted = false;
|
|
for (let i = 0; i < text.length; i++) {
|
|
const char = text[i];
|
|
const next = text[i + 1];
|
|
if (char === '"') {
|
|
if (quoted && next === '"') {
|
|
cell += '"';
|
|
i++;
|
|
} else {
|
|
quoted = !quoted;
|
|
}
|
|
} else if (char === delimiter && !quoted) {
|
|
row.push(cell);
|
|
cell = '';
|
|
} else if ((char === '\n' || char === '\r') && !quoted) {
|
|
if (char === '\r' && next === '\n') i++;
|
|
row.push(cell);
|
|
if (row.some(value => value !== '')) rows.push(row);
|
|
row = [];
|
|
cell = '';
|
|
} else {
|
|
cell += char;
|
|
}
|
|
}
|
|
row.push(cell);
|
|
if (row.some(value => value !== '')) rows.push(row);
|
|
return rows;
|
|
}
|
|
|
|
function selectedDelimiter(text) {
|
|
const value = el.delimiterSelect.value;
|
|
if (value !== 'auto') return value === '\\t' ? '\t' : value;
|
|
const firstLine = text.split(/\r?\n/).find(Boolean) || '';
|
|
const candidates = [',', ';', '\t', '|'];
|
|
return candidates.map(candidate => ({ candidate, count: firstLine.split(candidate).length - 1 })).sort((a, b) => b.count - a.count)[0].candidate;
|
|
}
|
|
|
|
function normalizeRows(rows) {
|
|
const width = rows.reduce((max, row) => Math.max(max, row.length), 0);
|
|
return rows.map(row => Array.from({ length: width }, (_, index) => stringifyCell(row[index] ?? '')));
|
|
}
|
|
|
|
function stringifyCell(value) {
|
|
if (value instanceof Date) return value.toLocaleString('de-DE');
|
|
if (value === null || value === undefined) return '';
|
|
if (typeof value === 'object') return JSON.stringify(value);
|
|
return String(value);
|
|
}
|
|
|
|
function clean(value) {
|
|
return stringifyCell(value).replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
function looksLikeDate(value) {
|
|
return /^\d{1,2}[./-]\d{1,2}[./-]\d{2,4}$/.test(clean(value));
|
|
}
|
|
|
|
function jsonToRows(value) {
|
|
if (Array.isArray(value)) {
|
|
const objects = value.filter(item => item && typeof item === 'object' && !Array.isArray(item));
|
|
if (objects.length === value.length) {
|
|
const headers = [...new Set(objects.flatMap(item => Object.keys(flattenObject(item))))];
|
|
return [headers, ...objects.map(item => {
|
|
const flat = flattenObject(item);
|
|
return headers.map(header => stringifyCell(flat[header]));
|
|
})];
|
|
}
|
|
return [['Index', 'Wert'], ...value.map((item, index) => [index + 1, stringifyCell(item)])];
|
|
}
|
|
if (value && typeof value === 'object') {
|
|
const flat = flattenObject(value);
|
|
return [['Pfad', 'Wert'], ...Object.entries(flat).map(([key, item]) => [key, stringifyCell(item)])];
|
|
}
|
|
return [['Wert'], [stringifyCell(value)]];
|
|
}
|
|
|
|
function flattenObject(input, prefix = '', output = {}) {
|
|
Object.entries(input || {}).forEach(([key, value]) => {
|
|
const path = prefix ? `${prefix}.${key}` : key;
|
|
if (value && typeof value === 'object' && !Array.isArray(value)) flattenObject(value, path, output);
|
|
else output[path] = Array.isArray(value) ? JSON.stringify(value) : value;
|
|
});
|
|
return output;
|
|
}
|
|
|
|
function xmlNodeToObject(node) {
|
|
const attributes = {};
|
|
[...node.attributes || []].forEach(attribute => attributes[`@${attribute.name}`] = attribute.value);
|
|
const children = [...node.children].map(xmlNodeToObject);
|
|
const text = [...node.childNodes].filter(child => child.nodeType === Node.TEXT_NODE).map(child => child.textContent.trim()).filter(Boolean).join(' ');
|
|
return { name: node.nodeName, ...(Object.keys(attributes).length ? { attributes } : {}), ...(text ? { text } : {}), ...(children.length ? { children } : {}) };
|
|
}
|
|
|
|
function xmlToRows(root) {
|
|
const rows = [['Pfad', 'Attribut', 'Text']];
|
|
walkXml(root, root.nodeName, rows);
|
|
return rows;
|
|
}
|
|
|
|
function walkXml(node, path, rows) {
|
|
const attrs = [...node.attributes || []].map(attribute => `${attribute.name}="${attribute.value}"`).join('; ');
|
|
const text = [...node.childNodes].filter(child => child.nodeType === Node.TEXT_NODE).map(child => child.textContent.trim()).filter(Boolean).join(' ');
|
|
rows.push([path, attrs, text]);
|
|
[...node.children].forEach((child, index) => walkXml(child, `${path}/${child.nodeName}[${index + 1}]`, rows));
|
|
}
|
|
|
|
function navigateSearchResult(direction) {
|
|
const sheet = activeSheet();
|
|
if (!sheet || !state.search) return;
|
|
const records = filteredRecords(sheet);
|
|
if (!records.length) return;
|
|
const currentIndex = Math.min(Math.max(state.selectedRecordIndex, 0), records.length - 1);
|
|
state.selectedRecordIndex = (currentIndex + direction + records.length) % records.length;
|
|
state.view = 'records';
|
|
document.querySelectorAll('.tab-btn').forEach(tab => tab.classList.toggle('active', tab.dataset.view === state.view));
|
|
render();
|
|
requestAnimationFrame(() => el.viewerBody.querySelector('.record-card.active')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }));
|
|
}
|
|
|
|
function renderSearchNavigation(sheet) {
|
|
const records = sheet && state.search ? filteredRecords(sheet) : [];
|
|
const enabled = Boolean(state.search && records.length);
|
|
const current = enabled ? Math.min(state.selectedRecordIndex + 1, records.length) : 0;
|
|
const position = enabled ? ` (${current}/${records.length})` : '';
|
|
el.searchPreviousBtn.disabled = !enabled;
|
|
el.searchNextBtn.disabled = !enabled;
|
|
el.searchPreviousBtn.title = `Vorheriger Suchtreffer${position}`;
|
|
el.searchNextBtn.title = `Nächster Suchtreffer${position}`;
|
|
}
|
|
function render() {
|
|
renderSheetList();
|
|
renderStats();
|
|
renderActionSummary();
|
|
const sheet = activeSheet();
|
|
renderColumnFilter(sheet);
|
|
renderPurpleKnightFilter();
|
|
renderSearchNavigation(sheet);
|
|
if (!sheet) {
|
|
el.visibleHeaderCount.textContent = '0';
|
|
return renderEmpty();
|
|
}
|
|
el.viewerTitle.textContent = `${state.file?.name || 'Daten'} · ${sheet.name}`;
|
|
const visibleCount = filteredRecords(sheet).length;
|
|
el.visibleHeaderCount.textContent = visibleCount.toLocaleString('de-DE');
|
|
const actionCount = actionRecords().length;
|
|
el.viewerHint.textContent = `${visibleCount.toLocaleString('de-DE')} lesbare Datensätze · ${actionCount.toLocaleString('de-DE')} in Actionlist · Header in Zeile ${sheet.analysis.headerIndex + 1}`;
|
|
if (state.view === 'records') renderRecords(sheet);
|
|
if (state.view === 'columns') renderColumns(sheet);
|
|
if (state.view === 'table') renderTable(sheet);
|
|
if (state.view === 'raw') renderRaw(sheet);
|
|
}
|
|
|
|
function renderSheetList() {
|
|
if (!state.sheets.length) {
|
|
el.sheetList.innerHTML = '<select class="select sheet-select" aria-label="Datei oder Tabellenblatt ausw\u00e4hlen" disabled><option>Keine Datei · 0</option></select>';
|
|
return;
|
|
}
|
|
const options = state.sheets.map((sheet, index) =>
|
|
`<option value="${index}" ${index === state.activeSheetIndex ? 'selected' : ''}>${escapeHtml(sheet.name)} · ${sheet.analysis.records.length.toLocaleString('de-DE')}</option>`
|
|
).join('');
|
|
el.sheetList.innerHTML = `<select class="select sheet-select" aria-label="Datei oder Tabellenblatt ausw\u00e4hlen">${options}</select>`;
|
|
el.sheetList.querySelector('.sheet-select').addEventListener('change', event => {
|
|
state.activeSheetIndex = Number(event.currentTarget.value);
|
|
state.selectedRecordIndex = 0;
|
|
render();
|
|
});
|
|
}
|
|
function renderStats() {
|
|
const sheet = activeSheet();
|
|
const analysis = sheet?.analysis;
|
|
el.rowCount.textContent = (analysis?.records.length || 0).toLocaleString('de-DE');
|
|
el.columnCount.textContent = (analysis?.headers.length || 0).toLocaleString('de-DE');
|
|
el.sheetCount.textContent = state.sheets.length.toLocaleString('de-DE');
|
|
el.formatLabel.textContent = state.format || '-';
|
|
}
|
|
|
|
function renderEmpty() {
|
|
el.viewerBody.innerHTML = `<div class="empty-state empty-import-state"><div>
|
|
<button class="btn primary" type="button" data-empty-open>Datei laden</button>
|
|
<div class="empty-support-grid">
|
|
<section><strong>Unterstützte Dateiformate</strong><ul>
|
|
<li>Excel (.xlsx, .xls)</li><li>CSV und Text (.csv, .txt)</li><li>JSON und Snapshot (.json)</li><li>XML (.xml)</li><li>HTML (.html, .htm)</li>
|
|
</ul></section>
|
|
<section><strong>Unterstützte Scantools</strong><ul>
|
|
<li>PurpleKnight</li><li>PingCastle</li><li>CIS-CAT Pro</li>
|
|
</ul></section>
|
|
</div>
|
|
</div></div>`;
|
|
el.viewerBody.querySelector('[data-empty-open]')?.addEventListener('click', () => el.fileInput.click());
|
|
}
|
|
|
|
|
|
|
|
function renderRecords(sheet, preservedRecordScroll = null) {
|
|
const records = filteredRecords(sheet);
|
|
if (!records.length) {
|
|
el.viewerBody.innerHTML = '<div class="empty-state"><div>Keine passenden Datensätze gefunden.</div></div>';
|
|
return;
|
|
}
|
|
const selectedIndex = Math.min(state.selectedRecordIndex, records.length - 1);
|
|
state.selectedRecordIndex = selectedIndex;
|
|
const selected = records[selectedIndex];
|
|
el.viewerBody.innerHTML = `
|
|
<div class="records-layout">
|
|
<div class="record-list">
|
|
${records.map((record, index) => `<button class="record-card ${recordSeverityClass(record)} ${reviewFor(record).include ? 'is-workspace' : ''} ${record === selected ? 'active' : ''}" type="button" data-record="${index}">
|
|
<span class="record-title">${highlight(displayTitle(record))}${reviewFor(record).include ? ' <span class="action-pill">Workspace</span>' : ''}</span>
|
|
<span class="record-meta">${visibleMeta(record).map(field => `<span>${escapeHtml(field.label)}: ${highlight(field.value)}</span>`).join('')}</span>
|
|
</button>`).join('')}
|
|
</div>
|
|
<div class="record-detail">${renderRecordArticle(selected, selectedIndex, records.length)}</div>
|
|
</div>`;
|
|
const recordList = el.viewerBody.querySelector('.record-list');
|
|
if (Number.isFinite(preservedRecordScroll) && recordList) recordList.scrollTop = preservedRecordScroll;
|
|
el.viewerBody.querySelectorAll('[data-record]').forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
const list = button.closest('.record-list');
|
|
const scrollTop = list ? list.scrollTop : null;
|
|
state.selectedRecordIndex = Number(button.dataset.record);
|
|
renderRecords(sheet, scrollTop);
|
|
});
|
|
});
|
|
el.viewerBody.querySelectorAll('[data-record-nav]').forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
state.selectedRecordIndex += Number(button.dataset.recordNav);
|
|
renderRecords(sheet);
|
|
});
|
|
});
|
|
el.viewerBody.querySelectorAll('[data-bookmark-toggle]').forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
const recordList = el.viewerBody.querySelector('.record-list');
|
|
const scrollTop = recordList ? recordList.scrollTop : null;
|
|
const key = reviewKey(selected);
|
|
updateReviewField(key, 'include', !reviewFor(selected).include);
|
|
renderRecords(sheet, scrollTop);
|
|
});
|
|
}); el.viewerBody.querySelectorAll('[data-results-jump]').forEach(button => {
|
|
button.addEventListener('click', () => openResultsPopup(selected));
|
|
});
|
|
el.viewerBody.querySelectorAll('[data-results-popup]').forEach(button => {
|
|
button.addEventListener('click', () => openResultsPopup(selected));
|
|
});
|
|
el.viewerBody.querySelectorAll('[data-review-field]').forEach(field => {
|
|
const eventName = field.type === 'checkbox' ? 'change' : 'input';
|
|
field.addEventListener(eventName, () => {
|
|
const value = field.type === 'checkbox' ? field.checked : field.value;
|
|
updateReviewField(field.dataset.reviewKey, field.dataset.reviewField, value);
|
|
if (field.classList.contains('review-textarea')) {
|
|
const preview = field.parentElement?.querySelector('[data-markdown-preview]');
|
|
if (preview) preview.innerHTML = renderMarkdown(value || '');
|
|
}
|
|
if (field.dataset.reviewField === 'include') renderRecords(sheet);
|
|
else renderActionSummary();
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderRecordArticle(record, recordIndex, recordCount) {
|
|
const model = readerModel(record);
|
|
const bookmarked = reviewFor(record).include;
|
|
const articleSeverityClass = recordSeverityClass(record);
|
|
const articleTitle = displayTitle(record);
|
|
const hasLinkedResults = linkedResultSheets(record).length > 0;
|
|
const contextLine = uniqueValues([model.group, model.source]).filter(value => !sameValue(value, articleTitle));
|
|
const headerBadges = readerHeaderBadges(record, model);
|
|
const mitreBadges = readerMitreBadges(record);
|
|
const visibleMetaLine = uniqueValues(model.metaLine).filter(value => !sameValue(value, articleTitle) && !headerBadges.some(badge => sameValue(badge.value, value)) && !contextLine.some(context => sameValue(context, value)));
|
|
return `
|
|
<article class="record-article reader-article">
|
|
<nav class="reader-nav" aria-label="Kontext">
|
|
<div class="reader-arrows">
|
|
<button class="reader-arrow-btn" type="button" data-record-nav="-1" aria-label="Vorheriger Datensatz" title="Vorheriger Datensatz" ${recordIndex <= 0 ? 'disabled' : ''}>‹</button>
|
|
<button class="reader-arrow-btn" type="button" data-record-nav="1" aria-label="Nächster Datensatz" title="Nächster Datensatz" ${recordIndex >= recordCount - 1 ? 'disabled' : ''}>›</button>
|
|
</div>
|
|
<div class="reader-crumbs">Inbox${contextLine.map(item => ` / ${escapeHtml(item)}`).join('')}</div>
|
|
|
|
</nav>
|
|
|
|
<div class="reader-sticky-heading">
|
|
<div class="reader-title-line">
|
|
<span class="reader-title-hex ${articleSeverityClass}" aria-hidden="true"></span>
|
|
<h2>${highlight(articleTitle)}</h2>
|
|
</div>
|
|
<div class="reader-meta-line">
|
|
${headerBadges.map(badge => `<span class="reader-severity${badge.className ? ` ${badge.className}` : ''}" title="${escapeHtml(badge.label)}" aria-label="${escapeHtml(badge.label)}: ${escapeHtml(badge.value)}">${escapeHtml(badge.text)}</span>`).join('')}
|
|
${visibleMetaLine.map(item => `<span class="reader-dot">·</span><span>${highlight(item)}</span>`).join('')}
|
|
<div class="reader-heading-actions">
|
|
<div class="reader-bookmark-control"><span class="reader-bookmark-label">Bookmark</span><button class="reader-bookmark-btn" type="button" data-bookmark-toggle aria-pressed="${bookmarked ? 'true' : 'false'}" title="${bookmarked ? 'Bookmark entfernen' : 'Als Bookmark markieren'}" aria-label="${bookmarked ? 'Bookmark entfernen' : 'Als Bookmark markieren'}"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 3h11l-1.5 4L17 11H8v10H6V3Z" fill="${bookmarked ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/></svg></button></div>
|
|
<button class="reader-mode-btn reader-results-btn" type="button" data-results-jump ${hasLinkedResults ? 'title="Results anzeigen"' : 'disabled title="Keine verkn\u00fcpften Results vorhanden"'}>Results</button>
|
|
</div>
|
|
</div>
|
|
${mitreBadges.length ? `<div class="reader-mitre-line">${mitreBadges.map(badge => `<span class="reader-severity reader-mitre-badge" title="${escapeHtml(badge.label)}">${escapeHtml(badge.text)}</span>`).join('')}</div>` : ''}
|
|
</div> ${renderRecordEditView(record, model)}
|
|
|
|
</article>`;
|
|
}
|
|
|
|
|
|
function readerHeaderBadges(record, model) {
|
|
const fields = displayFields(record);
|
|
const requested = [
|
|
['Category', /^category$/i],
|
|
['Severity', /^severity$/i],
|
|
['Weight', /^weight$/i],
|
|
['Score', /^score$/i]
|
|
].map(([label, pattern]) => {
|
|
const field = fields.find(candidate => pattern.test(clean(candidate.label)) && candidate.value);
|
|
if (!field) return null;
|
|
const value = plainTextValue(field.value);
|
|
return {
|
|
label,
|
|
value,
|
|
text: /^(?:Weight|Score)$/.test(label) ? `${label}: ${value}` : value,
|
|
className: label === 'Severity' ? severityBadgeClass(value) : ''
|
|
};
|
|
}).filter(Boolean);
|
|
const candidates = [{ label: 'Status', value: model.severity, text: model.severity, className: severityBadgeClass(model.severity) }, ...requested];
|
|
return candidates.filter((badge, index) => badge.value && !candidates.slice(0, index).some(previous => sameValue(previous.value, badge.value)));
|
|
}
|
|
|
|
function severityBadgeClass(value) {
|
|
const normalized = clean(value).toLowerCase();
|
|
if (/critical|kritisch/.test(normalized)) return 'severity-critical';
|
|
if (/high|hoch/.test(normalized)) return 'severity-high';
|
|
if (/medium|mittel|warning/.test(normalized)) return 'severity-medium';
|
|
if (/low|niedrig/.test(normalized)) return 'severity-low';
|
|
if (/info|informational/.test(normalized)) return 'severity-info';
|
|
return '';
|
|
}
|
|
|
|
function recordSeverityClass(record) {
|
|
const explicit = displayFields(record).find(field => /^(?:severity|kritikalit.t|criticality|priority|priorit.t)$/i.test(clean(field.label)) && field.value);
|
|
return severityBadgeClass(explicit?.value || readerModel(record).severity);
|
|
}
|
|
|
|
function mitreBadgeLabel(label) {
|
|
const normalized = clean(label);
|
|
if (/mitre[\s_-]*(?:att&?ck|attack)|(?:att&?ck|attack)[\s_-]*mitre/i.test(normalized)) return 'MITRE ATT&CK';
|
|
if (/mitre[\s_-]*(?:defen[cs]e|d3fend)|(?:defen[cs]e|d3fend)[\s_-]*mitre/i.test(normalized)) return /d3fend/i.test(normalized) ? 'MITRE D3FEND' : 'MITRE Defense';
|
|
return '';
|
|
}
|
|
|
|
function readerMitreBadges(record) {
|
|
const badges = displayFields(record).map(field => {
|
|
const label = mitreBadgeLabel(field.label);
|
|
const value = plainTextValue(field.value);
|
|
return label && value ? { label, value, text: `${label}: ${value}` } : null;
|
|
}).filter(Boolean);
|
|
return badges.filter((badge, index) => !badges.slice(0, index).some(previous => previous.label === badge.label && sameValue(previous.value, badge.value)));
|
|
}
|
|
function renderRecordEditView(record, model) {
|
|
const key = reviewKey(record);
|
|
const review = reviewFor(record);
|
|
const statuses = ['Offen', 'In Prüfung', 'Klärung nötig', 'Umsetzbar', 'Blockiert', 'Erledigt', 'Nicht relevant'];
|
|
const priorities = ['Hoch', 'Mittel', 'Niedrig'];
|
|
return `<section class="dashboard-view edit-dashboard-view">
|
|
<div class="dashboard-grid">
|
|
<section class="dashboard-card">${renderReviewSelect(key, 'status', 'Status', statuses, review.status)}</section>
|
|
<section class="dashboard-card">${renderReviewSelect(key, 'priority', 'Priorität', priorities, review.priority)}</section>
|
|
<section class="dashboard-card">${renderReviewInput(key, 'owner', 'Owner / Team', review.owner)}</section>
|
|
<section class="dashboard-card">${renderReviewInput(key, 'due', 'Frist', review.due, 'date')}</section>
|
|
<section class="dashboard-card full edit-summary-card"><div class="reader-label">Finding</div>${renderReaderSummary(model.summary)}</section>
|
|
<section class="dashboard-card full edit-action-card">
|
|
<div class="reader-label">Actionlist Details</div>
|
|
<div class="review-grid">
|
|
${renderReviewTextarea(key, 'prerequisites', 'Voraussetzungen', review.prerequisites)}
|
|
${renderReviewTextarea(key, 'access', 'Benötigte Zugriffe / Rollen', review.access)}
|
|
${renderReviewTextarea(key, 'nextStep', 'Nächster Schritt', review.nextStep)}
|
|
${renderReviewTextarea(key, 'evidence', 'Nachweis / Akzeptanzkriterium', review.evidence)}
|
|
${renderReviewTextarea(key, 'comment', 'Kommentar', review.comment)}
|
|
<div class="review-markers">
|
|
<div class="review-marker-label">Markierungen</div>
|
|
${renderReviewMarker(key, 'marker_question', 'Rückfrage nötig', review.marker_question)}
|
|
${renderReviewMarker(key, 'marker_blocked', 'Berechtigung blockiert', review.marker_blocked)}
|
|
${renderReviewMarker(key, 'marker_quickwin', 'Quick Win', review.marker_quickwin)}
|
|
${renderReviewMarker(key, 'marker_external', 'Externes Team', review.marker_external)}
|
|
${renderReviewMarker(key, 'marker_notrelevant', 'Nicht relevant', review.marker_notrelevant)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</section>${renderAllFields(record, model)}${renderLinkedResults(record)}`;
|
|
}
|
|
function readerModel(record) {
|
|
const fields = displayFields(record).filter(field => field.value);
|
|
const severityField = pickField(record, /kritikalität|critical|priority|priorität|severity|status/i);
|
|
const scoreField = pickField(record, /score|bewertung|id$/i);
|
|
const timeField = pickField(record, /zeit|datum|start|ende|date/i);
|
|
const sourceField = pickField(record, /quelle|source|system|tool/i);
|
|
const groupField = pickField(record, /gruppe|group|ebene|bereich|scope|norm|kategorie/i);
|
|
const urlField = pickField(record, /url|link/i);
|
|
const idField = pickField(record, /cve|ticket|referenz|reference|id/i);
|
|
const summaryFields = pickSummaryFields(record);
|
|
const metadata = buildReaderMetadata(record, fields, { sourceField, groupField, scoreField, urlField, idField });
|
|
return {
|
|
severity: plainTextValue(severityField?.value || 'INFO'),
|
|
metaLine: [scoreField?.value, timeField?.value, sourceField?.value, groupField?.value].filter(Boolean).slice(0, 5),
|
|
source: plainTextValue(sourceField?.value || 'exxTableReader'),
|
|
group: plainTextValue(groupField?.value || 'Datensatz'),
|
|
summary: summaryFields.length ? summaryFields : [{ label: 'Summary', value: articleLead(record) }],
|
|
topics: buildTopics(record, fields),
|
|
priority: buildPriority(record, fields, severityField),
|
|
ids: buildIds(record, fields, idField),
|
|
metadata
|
|
};
|
|
}
|
|
|
|
function defaultReview() {
|
|
return {
|
|
include: false, status: 'Offen', priority: 'Mittel', owner: '', due: '', prerequisites: '', access: '', nextStep: '', evidence: '', comment: '',
|
|
marker_question: false, marker_blocked: false, marker_quickwin: false, marker_external: false, marker_notrelevant: false
|
|
};
|
|
}
|
|
|
|
function renderAllFields(record, model) {
|
|
const fields = findingFields(record, model);
|
|
if (!fields.length) return '';
|
|
const rows = fields.map(field => '<tr><th>' + escapeHtml(field.label) + '</th><td>' + highlight(field.value) + '</td></tr>').join('');
|
|
return '<details class="reader-metadata reader-all-fields"><summary class="reader-label">Weitere Finding-Informationen</summary><table class="reader-meta-table"><tbody>' + rows + '</tbody></table></details>';
|
|
}
|
|
|
|
function renderLinkedResults(record) {
|
|
const referenceFields = resultReferenceFields(record);
|
|
if (!referenceFields.length) return '';
|
|
const links = linkedResultSheets(record);
|
|
const total = links.reduce((sum, item) => sum + item.sheet.analysis.records.length, 0);
|
|
const summary = links.length ? `Verkn\u00fcpfte Results (${total.toLocaleString('de-DE')})` : 'Results (Referenz gefunden)';
|
|
const content = links.length
|
|
? renderLinkedResultSections(links)
|
|
: `<div class="linked-results-empty"><strong>RESULT-Verweis gefunden, aber keine passende Ergebnisdatei wurde automatisch zugeordnet.</strong><br>Referenz: ${referenceFields.map(field => highlight(field.value)).join('<br>')}<br>Bitte pr\u00fcfen, ob die referenzierte Datei im selben Projekt mitgeladen wurde.</div>`;
|
|
return `<details class="reader-metadata reader-all-fields linked-results" data-results-section><summary class="reader-label">${summary}</summary><div class="linked-results-body"><div class="linked-results-toolbar"><button class="reader-mode-btn" type="button" data-results-popup>Im Fenster anzeigen</button></div>${content}</div></details>`;
|
|
}
|
|
|
|
function renderLinkedResultSections(links) {
|
|
return links.map(item => {
|
|
const records = item.sheet.analysis.records;
|
|
const headers = item.sheet.analysis.headers.filter(header => records.some(resultRecord => {
|
|
const field = resultRecord.fields.find(candidate => candidate.label === header);
|
|
return Boolean(field && plainTextValue(field.value).trim());
|
|
}));
|
|
const head = headers.map(header => `<th>${escapeHtml(header)}</th>`).join('');
|
|
const body = records.map(resultRecord => {
|
|
const values = new Map(resultRecord.fields.map(field => [field.label, field.value]));
|
|
return `<tr>${headers.map(header => `<td>${highlight(values.get(header) || '')}</td>`).join('')}</tr>`;
|
|
}).join('');
|
|
return `<section><div class="linked-results-source">${escapeHtml(item.sheet.sourceFile || item.sheet.name)} · ${records.length.toLocaleString('de-DE')} Ergebnis${records.length === 1 ? '' : 'se'}</div><div class="linked-results-scroll"><table class="linked-results-table"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div></section>`;
|
|
}).join('');
|
|
}
|
|
|
|
function openTileResultsPopup(record) {
|
|
const key = reviewKey(record);
|
|
const existing = tileResultsWindows.get(key);
|
|
if (existing?.popup?.isConnected) {
|
|
existing.popup.style.zIndex = String(++floatingResultsZ);
|
|
existing.popup.querySelector('.results-floating-close')?.focus({ preventScroll: true });
|
|
return;
|
|
}
|
|
|
|
const referenceFields = resultReferenceFields(record);
|
|
const links = linkedResultSheets(record);
|
|
const total = links.reduce((sum, item) => sum + item.sheet.analysis.records.length, 0);
|
|
const title = !referenceFields.length
|
|
? `Results - ${plainTextValue(displayTitle(record))}`
|
|
: links.length
|
|
? `Results (${total.toLocaleString('de-DE')}) - ${plainTextValue(displayTitle(record))}`
|
|
: `Results-Referenz - ${plainTextValue(displayTitle(record))}`;
|
|
const body = !referenceFields.length
|
|
? '<div class="linked-results-empty"><strong>Dieser Datensatz enthält keinen RESULT-Verweis.</strong></div>'
|
|
: links.length
|
|
? renderLinkedResultSections(links)
|
|
: `<div class="linked-results-empty"><strong>Keine passende Ergebnisdatei automatisch gefunden.</strong><br>Referenz: ${referenceFields.map(field => highlight(field.value)).join('<br>')}<br>Die referenzierte Datei muss im selben Projekt geladen sein.</div>`;
|
|
|
|
const color = tileResultsPalette[tileResultsColorIndex++ % tileResultsPalette.length];
|
|
const popup = document.createElement('section');
|
|
popup.className = 'results-floating-window tile-results-window';
|
|
popup.setAttribute('role', 'dialog');
|
|
popup.setAttribute('aria-label', title);
|
|
popup.style.setProperty('--result-color', color);
|
|
popup.style.zIndex = String(++floatingResultsZ);
|
|
popup.innerHTML = `
|
|
<header class="results-floating-head">
|
|
<div class="results-floating-title">${escapeHtml(title)}</div>
|
|
<button class="results-floating-close" type="button" aria-label="Results-Fenster schliessen" title="Schliessen">×</button>
|
|
</header>
|
|
<div class="results-floating-body">${body}</div>`;
|
|
document.body.appendChild(popup);
|
|
|
|
const offset = tileResultsWindows.size * 24;
|
|
popup.style.left = Math.max(8, Math.min(window.innerWidth - popup.offsetWidth - 8, 40 + offset)) + 'px';
|
|
popup.style.top = Math.max(8, Math.min(window.innerHeight - popup.offsetHeight - 8, 56 + offset)) + 'px';
|
|
tileResultsWindows.set(key, { popup, color });
|
|
|
|
const header = popup.querySelector('.results-floating-head');
|
|
const close = popup.querySelector('.results-floating-close');
|
|
let drag = null;
|
|
const raise = () => { popup.style.zIndex = String(++floatingResultsZ); };
|
|
popup.addEventListener('pointerdown', raise);
|
|
header.addEventListener('pointerdown', event => {
|
|
if (event.target.closest('button')) return;
|
|
const rect = popup.getBoundingClientRect();
|
|
drag = { pointerId: event.pointerId, offsetX: event.clientX - rect.left, offsetY: event.clientY - rect.top };
|
|
header.setPointerCapture(event.pointerId);
|
|
event.preventDefault();
|
|
});
|
|
header.addEventListener('pointermove', event => {
|
|
if (!drag || drag.pointerId !== event.pointerId) return;
|
|
const maxLeft = Math.max(0, window.innerWidth - popup.offsetWidth);
|
|
const maxTop = Math.max(0, window.innerHeight - popup.offsetHeight);
|
|
popup.style.left = Math.min(maxLeft, Math.max(0, event.clientX - drag.offsetX)) + 'px';
|
|
popup.style.top = Math.min(maxTop, Math.max(0, event.clientY - drag.offsetY)) + 'px';
|
|
});
|
|
const stopDrag = event => {
|
|
if (!drag || drag.pointerId !== event.pointerId) return;
|
|
drag = null;
|
|
if (header.hasPointerCapture(event.pointerId)) header.releasePointerCapture(event.pointerId);
|
|
};
|
|
header.addEventListener('pointerup', stopDrag);
|
|
header.addEventListener('pointercancel', stopDrag);
|
|
close.addEventListener('click', event => {
|
|
event.stopPropagation();
|
|
const scrollTop = el.viewerBody.querySelector('.viewer-scroll')?.scrollTop ?? null;
|
|
tileResultsWindows.delete(key);
|
|
popup.remove();
|
|
if (state.view === 'columns' && activeSheet()) renderColumns(activeSheet(), scrollTop);
|
|
});
|
|
close.focus({ preventScroll: true });
|
|
|
|
const scrollTop = el.viewerBody.querySelector('.viewer-scroll')?.scrollTop ?? null;
|
|
if (state.view === 'columns' && activeSheet()) renderColumns(activeSheet(), scrollTop);
|
|
}
|
|
|
|
function openResultsPopup(record) {
|
|
const referenceFields = resultReferenceFields(record);
|
|
const links = linkedResultSheets(record);
|
|
const total = links.reduce((sum, item) => sum + item.sheet.analysis.records.length, 0);
|
|
el.resultsPopupTitle.textContent = !referenceFields.length
|
|
? `Results - ${plainTextValue(displayTitle(record))}`
|
|
: links.length
|
|
? `Results (${total.toLocaleString('de-DE')}) - ${plainTextValue(displayTitle(record))}`
|
|
: `Results-Referenz - ${plainTextValue(displayTitle(record))}`;
|
|
el.resultsPopupBody.innerHTML = !referenceFields.length
|
|
? '<div class="linked-results-empty"><strong>Dieser Datensatz enth\u00e4lt keinen RESULT-Verweis.</strong></div>'
|
|
: links.length
|
|
? renderLinkedResultSections(links)
|
|
: `<div class="linked-results-empty"><strong>Keine passende Ergebnisdatei automatisch gefunden.</strong><br>Referenz: ${referenceFields.map(field => highlight(field.value)).join('<br>')}<br>Die referenzierte Datei muss im selben Projekt geladen sein.</div>`;
|
|
el.resultsPopup.hidden = false;
|
|
el.resultsPopup.style.zIndex = '2001';
|
|
el.resultsPopupClose.focus({ preventScroll: true });
|
|
}
|
|
|
|
function resultReferenceFields(record) {
|
|
return displayFields(record).filter(field => field.value && /^(?:result|results|result\s*(?:file|datei)|resultdatei|ergebnis|ergebnisse|ergebnisdatei|details?)$/i.test(clean(field.label)));
|
|
}
|
|
|
|
function resultReferenceValues(record) {
|
|
return resultReferenceFields(record).flatMap(field => {
|
|
const raw = String(field.value || '');
|
|
const hrefs = [...raw.matchAll(/href\s*=\s*['"]([^'"]+)['"]/ig)].map(match => {
|
|
try { return decodeURIComponent(match[1]); } catch { return match[1]; }
|
|
});
|
|
return [plainTextValue(raw), raw, ...hrefs];
|
|
}).filter(Boolean);
|
|
}
|
|
|
|
function linkedResultSheets(record) {
|
|
const referenceFields = resultReferenceFields(record);
|
|
if (!referenceFields.length || state.sheets.length < 2) return [];
|
|
const references = resultReferenceValues(record);
|
|
const tokenSource = [...references, ...displayFields(record).map(field => plainTextValue(field.value))].join(' ');
|
|
const identityTokens = [...tokenSource.matchAll(/(?:^|[^a-z0-9])((?:SI|ID|IND)[-_ ]?\d{3,})(?=$|[^a-z0-9])/ig)].map(match => match[1]);
|
|
const candidates = state.sheets.map((sheet, index) => ({ sheet, index, score: linkedSheetScore(sheet, references, identityTokens) }))
|
|
.filter(item => !item.sheet.isProjectSheet && item.index !== state.activeSheetIndex && item.score >= 65)
|
|
.sort((left, right) => right.score - left.score);
|
|
if (!candidates.length) return [];
|
|
const bestScore = candidates[0].score;
|
|
return bestScore >= 100 ? candidates.filter(item => item.score === bestScore) : [candidates[0]];
|
|
}
|
|
function linkedSheetScore(sheet, references, identityTokens) {
|
|
const names = uniqueValues([sheet.sourceFile, sheet.sourceSheet, sheet.name]).filter(Boolean);
|
|
let score = 0;
|
|
references.forEach(reference => names.forEach(name => {
|
|
const referenceKey = linkedFileKey(reference);
|
|
const nameKey = linkedFileKey(name);
|
|
if (!referenceKey || !nameKey) return;
|
|
if (referenceKey === nameKey) score = Math.max(score, 120);
|
|
else if (Math.min(referenceKey.length, nameKey.length) >= 16 && (referenceKey.includes(nameKey) || nameKey.includes(referenceKey))) score = Math.max(score, 100);
|
|
}));
|
|
const normalizedNames = names.map(linkedFileKey).join(' ');
|
|
if (identityTokens.some(token => normalizedNames.includes(linkedFileKey(token)))) score = Math.max(score, 80);
|
|
const referenceWords = new Set(references.flatMap(value => linkedFileWords(value)));
|
|
const sharedWords = names.flatMap(value => linkedFileWords(value)).filter(word => referenceWords.has(word));
|
|
if (sharedWords.length >= 3) score = Math.max(score, 65 + Math.min(10, sharedWords.length));
|
|
return score;
|
|
}
|
|
|
|
function linkedFileWords(value) {
|
|
return plainTextValue(value).toLocaleLowerCase('de-DE').split(/[^a-z0-9]+/).filter(word => word.length >= 4 && !['results', 'result', 'report', 'security', 'assessment'].includes(word));
|
|
}
|
|
|
|
function linkedFileKey(value) {
|
|
return plainTextValue(value).toLocaleLowerCase('de-DE')
|
|
.replace(/^.*[\\/]/, '')
|
|
.replace(/\.(?:csv|tsv|xlsx?|xlsb|ods|json|html?)\b.*$/i, '')
|
|
.replace(/[^a-z0-9]+/g, '');
|
|
}
|
|
|
|
function findingFields(record, model) {
|
|
const shownValues = uniqueValues([
|
|
displayTitle(record),
|
|
model?.severity,
|
|
...(model?.metaLine || []),
|
|
...(model?.summary || []).map(field => field.value),
|
|
...(model ? readerHeaderBadges(record, model).map(badge => badge.value) : [])
|
|
]);
|
|
return displayFields(record).filter(field => field.value && !shownValues.some(value => sameValue(value, field.value)));
|
|
}
|
|
|
|
function uniqueValues(values) {
|
|
return [...new Set(values.map(value => plainTextValue(value)).filter(Boolean))];
|
|
}
|
|
|
|
function sameValue(left, right) {
|
|
return plainTextValue(left).toLocaleLowerCase('de-DE') === plainTextValue(right).toLocaleLowerCase('de-DE');
|
|
}
|
|
|
|
function renderReviewInput(key, field, label, value, type = 'text') {
|
|
return `<div class="review-field"><label>${escapeHtml(label)}</label><input class="review-input" type="${type}" data-review-key="${escapeHtml(key)}" data-review-field="${field}" value="${escapeHtml(value || '')}"></div>`;
|
|
}
|
|
|
|
function renderReviewTextarea(key, field, label, value) {
|
|
const markdown = renderMarkdown(value || '');
|
|
return `<div class="review-field full"><label>${escapeHtml(label)}</label><textarea class="review-textarea" data-review-key="${escapeHtml(key)}" data-review-field="${field}">${escapeHtml(value || '')}</textarea><div class="markdown-preview markdown-content" data-markdown-preview>${markdown}</div></div>`;
|
|
}
|
|
|
|
function renderReviewSelect(key, field, label, options, value) {
|
|
return `<div class="review-field"><label>${escapeHtml(label)}</label><select class="review-select" data-review-key="${escapeHtml(key)}" data-review-field="${field}">${options.map(option => `<option value="${escapeHtml(option)}" ${option === value ? 'selected' : ''}>${escapeHtml(option)}</option>`).join('')}</select></div>`;
|
|
}
|
|
|
|
function renderReviewMarker(key, field, label, checked) {
|
|
return `<label class="review-marker"><input type="checkbox" data-review-key="${escapeHtml(key)}" data-review-field="${field}" ${checked ? 'checked' : ''}> ${escapeHtml(label)}</label>`;
|
|
}
|
|
|
|
function renderReaderSummary(fields) {
|
|
const cleaned = fields
|
|
.map(field => ({ label: clean(field.label), value: clean(field.value) }))
|
|
.filter(field => field.value)
|
|
.slice(0, 10);
|
|
if (!cleaned.length) return '<p class="reader-summary">Keine Zusammenfassung erkannt.</p>';
|
|
const sections = cleaned.map(field => `<section class="reader-summary-text${field.value.length > 220 ? ' wide' : ''}">
|
|
<h3>${escapeHtml(field.label || 'Summary')}</h3>
|
|
<p>${highlight(field.value)}</p>
|
|
</section>`).join('');
|
|
return `<div class="reader-summary-text-list">${sections}</div>`;
|
|
}
|
|
|
|
function pickSummaryFields(record) {
|
|
const fields = displayFields(record);
|
|
const source = fields.filter(field => /^(?:source|quelle)$/i.test(clean(field.label)) && field.value);
|
|
const resultMessage = fields.filter(field => /^result[\s_-]*message$/i.test(clean(field.label)) && field.value);
|
|
const numberOfResults = fields.filter(field => /^number[\s_-]*of[\s_-]*results?$/i.test(clean(field.label)) && field.value);
|
|
const description = fields.filter(field => /^description$/i.test(clean(field.label)) && field.value);
|
|
const remediation = fields.filter(field => /^remediation(?:[\s_-]+(?:guidance|recommendation))?$/i.test(clean(field.label)) && field.value);
|
|
const nonMitre = field => !mitreBadgeLabel(field.label);
|
|
const preferred = fields.filter(field => nonMitre(field) && /beschreibung|summary|zusammenfassung|aufarbeitung|abarbeitung|t\u00e4tigkeit|ziel|ergebnis|notiz/i.test(field.label) && field.value);
|
|
const long = fields.filter(field => nonMitre(field) && clean(field.value).length > 80 && field.value !== record.title);
|
|
const existingSummary = uniqueFieldList([...preferred, ...long]).slice(0, 5);
|
|
return uniqueFieldList([...source, ...resultMessage, ...numberOfResults, ...description, ...remediation, ...existingSummary]);
|
|
}
|
|
function buildTopics(record, fields) {
|
|
const topicFields = fields.filter(field => /topic|tag|mitre|norm|quelle|scope|gruppe|ebene|kategorie|bezug/i.test(field.label));
|
|
const values = topicFields.flatMap(field => splitTopicValue(field.value));
|
|
if (!values.length) values.push(...fields.filter(field => clean(field.value).length <= 32).slice(0, 5).map(field => field.value));
|
|
return [...new Set(values.map(clean).filter(Boolean))].slice(0, 7);
|
|
}
|
|
|
|
function splitTopicValue(value) {
|
|
return clean(value).split(/[,;/|]+/).map(item => item.trim()).filter(Boolean).filter(item => item.length <= 48);
|
|
}
|
|
|
|
function buildPriority(record, fields, severityField) {
|
|
const priority = uniqueFieldList([
|
|
severityField,
|
|
...fields.filter(field => /kritikalität|priority|priorität|risiko|status|zustand|score|wirkung|impact/i.test(field.label))
|
|
].filter(Boolean));
|
|
return priority.length ? priority.slice(0, 4).map(field => field.value) : ['Keine Priorität erkannt'];
|
|
}
|
|
|
|
function buildIds(record, fields, idField) {
|
|
const ids = uniqueFieldList([
|
|
idField,
|
|
...fields.filter(field => /cve|ticket|referenz|reference|url|link|id/i.test(field.label))
|
|
].filter(Boolean));
|
|
return ids.length ? ids.slice(0, 4).map(field => field.value) : ['Keine IDs'];
|
|
}
|
|
|
|
function buildReaderMetadata(record, fields, picked) {
|
|
const preferred = [
|
|
['Source', picked.sourceField],
|
|
['Group', picked.groupField],
|
|
['Score', picked.scoreField],
|
|
['URL', picked.urlField],
|
|
['ID', picked.idField]
|
|
].filter(([, field]) => field?.value).map(([label, field]) => ({ label, value: field.value }));
|
|
const used = new Set(preferred.map(row => row.value));
|
|
const remaining = fields.filter(field => !used.has(field.value) && clean(field.value).length <= 140).slice(0, 8).map(field => ({ label: field.label, value: field.value }));
|
|
return [...preferred, ...remaining].slice(0, 10);
|
|
}
|
|
|
|
function pickField(record, pattern) {
|
|
return displayFields(record).find(field => pattern.test(field.label) && field.value) || null;
|
|
}
|
|
|
|
function uniqueFieldList(fields) {
|
|
const seen = new Set();
|
|
return fields.filter(field => {
|
|
const key = `${field.label}::${field.value}`;
|
|
if (seen.has(key)) return false;
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
} function articleLead(record) {
|
|
const fields = Object.fromEntries(record.filled.map(field => [field.label.toLowerCase(), field.value]));
|
|
const scope = findField(record, /scope|domäne|bereich/);
|
|
const goal = findField(record, /ziel|soll/);
|
|
const status = findField(record, /status|zustand|phase/);
|
|
const activity = findField(record, /tätigkeit|aktivität|aufgabe|methode|tool/);
|
|
const pieces = [];
|
|
if (scope) pieces.push(`Im Bereich ${scope.value}`);
|
|
if (activity) pieces.push(`geht es um ${activity.value}`);
|
|
if (goal) pieces.push(`mit dem Ziel ${goal.value}`);
|
|
if (status) pieces.push(`Der aktuelle Stand ist ${status.value}`);
|
|
return pieces.length ? `${pieces.join('. ')}.` : `Dieser Datensatz fasst ${displayFields(record).filter(field => field.value).length} ausgefüllte Informationen zusammen.`;
|
|
}
|
|
|
|
function articleSections(record) {
|
|
const sourceFields = displayFields(record).filter(field => field.value);
|
|
const groups = [
|
|
{ title: 'Ziel und Einordnung', test: /scope|domäne|bereich|ziel|soll|definition/i, mode: 'text' },
|
|
{ title: 'Durchführung', test: /tätigkeit|aktivität|aufgabe|tool|methode|workshop|scan|ableitung|arbeitspaket/i, mode: 'text' },
|
|
{ title: 'Status und Ergebnis', test: /status|ist|zustand|ergebnis|score|pass|fail|priorität|risiko/i, mode: 'facts' },
|
|
{ title: 'Normbezug und Hinweise', test: /norm|notiz|kommentar|abhängig|referenz|bezug/i, mode: 'text' }
|
|
];
|
|
const used = new Set();
|
|
const sections = groups.map(group => {
|
|
const fields = sourceFields.filter(field => group.test.test(field.label) && !used.has(field.label));
|
|
fields.forEach(field => used.add(field.label));
|
|
return { ...group, fields };
|
|
}).filter(section => section.fields.length);
|
|
const other = sourceFields.filter(field => !used.has(field.label) && field.value !== record.title);
|
|
if (other.length) sections.push({ title: 'Weitere Informationen', fields: other, mode: 'facts' });
|
|
return sections;
|
|
}
|
|
|
|
function renderArticleFacts(fields) {
|
|
return `<div class="article-facts">${fields.map(field => `
|
|
<div class="article-fact ${fieldDensityClass(field)}"><b>${escapeHtml(field.label)}</b><span>${highlight(field.value)}</span></div>
|
|
`).join('')}</div>`;
|
|
}
|
|
|
|
function fieldDensityClass(field) {
|
|
const value = clean(field.value);
|
|
const length = value.length;
|
|
const label = clean(field.label).length;
|
|
if (length > 260 || value.includes(';') && length > 170) return 'full';
|
|
if (length > 135) return 'long';
|
|
if (length > 70 || label > 24) return 'wide';
|
|
if (length > 28) return 'medium';
|
|
return 'compact';
|
|
}
|
|
|
|
function findField(record, pattern) {
|
|
return displayFields(record).find(field => pattern.test(field.label) && field.value);
|
|
}
|
|
|
|
function renderColumns(sheet, preservedScroll = null) {
|
|
const records = filteredRecords(sheet);
|
|
if (!records.length) {
|
|
el.viewerBody.innerHTML = '<div class="empty-state"><div>Keine passenden Datensätze für die Kachelansicht gefunden.</div></div>';
|
|
return;
|
|
}
|
|
el.viewerBody.innerHTML = `<div class="viewer-scroll"><div class="tile-grid">${records.map((record, recordIndex) => {
|
|
const heading = tileHeading(record, sheet);
|
|
const model = readerModel(record);
|
|
const badges = readerHeaderBadges(record, model);
|
|
const bookmarked = reviewFor(record).include;
|
|
const hasResults = linkedResultSheets(record).length > 0;
|
|
const resultWindow = tileResultsWindows.get(reviewKey(record));
|
|
return `<article class="data-tile ${recordSeverityClass(record)}${bookmarked ? ' is-bookmarked' : ''}" tabindex="0" role="button" data-tile-record="${recordIndex}">
|
|
<span class="data-tile-hex" aria-hidden="true"></span>
|
|
<div class="data-tile-content">
|
|
<h3>${highlight(heading.value)}</h3>
|
|
<div class="data-tile-badges">${badges.map(badge => `<span class="reader-severity${badge.className ? ` ${badge.className}` : ''}">${escapeHtml(badge.text)}</span>`).join('')}</div>
|
|
</div>
|
|
<div class="data-tile-actions">
|
|
<button class="reader-bookmark-btn tile-bookmark-btn" type="button" data-tile-bookmark="${recordIndex}" aria-pressed="${bookmarked ? 'true' : 'false'}" title="${bookmarked ? 'Bookmark entfernen' : 'Als Bookmark markieren'}" aria-label="${bookmarked ? 'Bookmark entfernen' : 'Als Bookmark markieren'}"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 3h11l-1.5 4L17 11H8v10H6V3Z" fill="${bookmarked ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/></svg></button>
|
|
${hasResults ? `<button class="tile-results-btn${resultWindow ? ' is-open' : ''}" type="button" data-tile-results="${recordIndex}" ${resultWindow ? `style="--result-color:${resultWindow.color}"` : ''} title="Results anzeigen" aria-label="Results anzeigen">R</button>` : ''}
|
|
</div>
|
|
</article>`;
|
|
}).join('')}</div></div>`;
|
|
const scroll = el.viewerBody.querySelector('.viewer-scroll');
|
|
if (Number.isFinite(preservedScroll) && scroll) scroll.scrollTop = preservedScroll;
|
|
el.viewerBody.querySelectorAll('[data-tile-record]').forEach(tile => {
|
|
const openRecord = () => {
|
|
state.view = 'records';
|
|
state.selectedRecordIndex = Number(tile.dataset.tileRecord);
|
|
document.querySelectorAll('.tab-btn').forEach(tab => tab.classList.toggle('active', tab.dataset.view === state.view));
|
|
render();
|
|
};
|
|
tile.addEventListener('click', openRecord);
|
|
tile.addEventListener('keydown', event => {
|
|
if (event.target.closest('[data-tile-bookmark], [data-tile-results]') || (event.key !== 'Enter' && event.key !== ' ')) return;
|
|
event.preventDefault();
|
|
openRecord();
|
|
});
|
|
});
|
|
el.viewerBody.querySelectorAll('[data-tile-bookmark]').forEach(button => {
|
|
button.addEventListener('click', event => {
|
|
event.stopPropagation();
|
|
const scrollTop = button.closest('.viewer-scroll')?.scrollTop ?? null;
|
|
const record = records[Number(button.dataset.tileBookmark)];
|
|
updateReviewField(reviewKey(record), 'include', !reviewFor(record).include);
|
|
renderColumns(sheet, scrollTop);
|
|
});
|
|
});
|
|
el.viewerBody.querySelectorAll('[data-tile-results]').forEach(button => {
|
|
button.addEventListener('click', event => {
|
|
event.stopPropagation();
|
|
openTileResultsPopup(records[Number(button.dataset.tileResults)]);
|
|
});
|
|
});
|
|
}
|
|
|
|
function tileHeading(record, sheet) {
|
|
if (state.titleColumn) {
|
|
const index = record.fields.findIndex(field => field.label === state.titleColumn);
|
|
const field = record.fields[index];
|
|
if (field?.value) return { index, label: field.label, value: field.value };
|
|
}
|
|
const index = record.fields.findIndex(field => field.value && field.value === record.title);
|
|
return {
|
|
index,
|
|
label: index >= 0 ? record.fields[index].label : 'Datensatz',
|
|
value: displayTitle(record)
|
|
};
|
|
}
|
|
|
|
function tileDetails(record, sheet, headingIndex) {
|
|
return record.fields
|
|
.map((field, index) => ({ ...field, index }))
|
|
.filter(field => field.value && field.index !== headingIndex)
|
|
.sort((left, right) => tileFieldScore(right, sheet) - tileFieldScore(left, sheet) || left.index - right.index)
|
|
.slice(0, 3);
|
|
}
|
|
|
|
function tileFieldScore(field, sheet) {
|
|
const label = field.label.toLowerCase();
|
|
const priorities = [
|
|
[/status|zustand|severity|kritikalität|priority|priorität|risiko/, 120],
|
|
[/score|bewertung|target|ziel/, 105],
|
|
[/category|kategorie|bereich|scope|gruppe|norm/, 90],
|
|
[/datum|date|zeit|runtime|frist|owner|team/, 75],
|
|
[/beschreibung|description|summary|name|titel|shortname/, 65]
|
|
];
|
|
const semanticScore = priorities.find(([pattern]) => pattern.test(label))?.[1] || 40;
|
|
const column = sheet.analysis.columns[field.index];
|
|
const completenessScore = column ? column.fillRate / 5 : 0;
|
|
const lengthPenalty = field.value.length > 280 ? 12 : 0;
|
|
return semanticScore + completenessScore - lengthPenalty;
|
|
}
|
|
|
|
function renderTable(sheet) {
|
|
const rows = filteredRows(sheet);
|
|
if (!rows.length) {
|
|
el.viewerBody.innerHTML = '<div class="empty-state"><div>Keine Treffer für die aktuelle Suche.</div></div>';
|
|
return;
|
|
}
|
|
const visibleIndexes = displayColumnIndexes(sheet);
|
|
const header = visibleIndexes.map(index => sheet.analysis.headers[index] || `Spalte ${index + 1}`);
|
|
el.viewerBody.innerHTML = `
|
|
<div class="table-scroll">
|
|
<table>
|
|
<thead><tr><th class="row-index">#</th>${header.map(cell => `<th>${escapeHtml(cell)}</th>`).join('')}</tr></thead>
|
|
<tbody>${rows.map(record => `<tr><td class="row-index">${record.index + 1}</td>${visibleIndexes.map(cellIndex => `<td>${highlight(record.fields[cellIndex]?.value || '')}</td>`).join('')}</tr>`).join('')}</tbody>
|
|
</table>
|
|
</div>`;
|
|
}
|
|
|
|
function renderRaw(sheet) {
|
|
if (!hasActiveFilter()) {
|
|
el.viewerBody.innerHTML = `<div class="viewer-scroll"><pre>${highlight(sheet.raw || state.rawText || '')}</pre></div>`;
|
|
return;
|
|
}
|
|
const rows = filteredRecords(sheet).map(record => {
|
|
const fields = displayFields(record).filter(field => field.value);
|
|
return [`# Datensatz ${record.index + 1}`, ...fields.map(field => `${field.label}: ${field.value}`)].join('\n');
|
|
}).join('\n\n');
|
|
el.viewerBody.innerHTML = `<div class="viewer-scroll"><pre>${highlight(rows || 'Keine Treffer für die aktuelle Filterung.')}</pre></div>`;
|
|
}
|
|
|
|
function filteredRecords(sheet) {
|
|
const records = sheet.analysis.records.filter(record => {
|
|
if (state.actionOnly && !reviewFor(record).include) return false;
|
|
if (state.purpleKnightFilter !== 'all' && !recordMatchesPurpleKnightStatus(record, state.purpleKnightFilter)) return false;
|
|
if (!state.search) return true;
|
|
return record.fields.some(field => isTextSearchField(field) && plainTextValue(field.value).toLowerCase().includes(state.search));
|
|
});
|
|
return sortRecords(records, sheet);
|
|
}
|
|
|
|
function recordMatchesPurpleKnightStatus(record, status) {
|
|
const values = record.fields.map(field => plainTextValue(field.value).toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim());
|
|
const has = (...aliases) => aliases.some(alias => values.includes(alias));
|
|
if (status === 'evaluated') return has('evaluated', 'ioes found', 'ioe found', 'passed', 'pass');
|
|
if (status === 'not-selected') return has('not selected');
|
|
if (status === 'ioes-found') return has('ioes found', 'ioe found');
|
|
if (status === 'passed') return has('passed', 'pass');
|
|
if (status === 'failed-to-run') return has('failed to run');
|
|
if (status === 'not-relevant') return has('not relevant', 'irrelevant');
|
|
if (status === 'canceled') return has('canceled', 'cancelled');
|
|
return true;
|
|
}
|
|
function isTextSearchField(field) {
|
|
if (!field?.value) return false;
|
|
return !/^quelle:\s*(datei|blatt)$/i.test(clean(field.label));
|
|
}
|
|
|
|
function sortRecords(records, sheet) {
|
|
if (state.columnFilter === 'all') return records;
|
|
const columnIndex = sheet?.analysis.headers.indexOf(state.columnFilter) ?? -1;
|
|
if (columnIndex < 0) return records;
|
|
const direction = state.sortDirection === 'asc' ? 1 : -1;
|
|
return [...records].sort((left, right) => {
|
|
const leftValue = parseNumericValue(left.fields[columnIndex]?.value);
|
|
const rightValue = parseNumericValue(right.fields[columnIndex]?.value);
|
|
if (leftValue === null && rightValue === null) return left.index - right.index;
|
|
if (leftValue === null) return 1;
|
|
if (rightValue === null) return -1;
|
|
return (leftValue - rightValue) * direction || left.index - right.index;
|
|
});
|
|
}
|
|
|
|
function parseNumericValue(value) {
|
|
let source = clean(value).replace(/[\s']/g, '').replace(/%$/, '');
|
|
if (!source) return null;
|
|
if (/^[+-]?\d{1,3}(?:\.\d{3})+(?:,\d+)?$/.test(source)) {
|
|
source = source.replace(/\./g, '').replace(',', '.');
|
|
} else if (/^[+-]?\d{1,3}(?:,\d{3})+(?:\.\d+)?$/.test(source)) {
|
|
source = source.replace(/,/g, '');
|
|
} else if (/^[+-]?(?:\d+(?:[.,]\d+)?|[.,]\d+)$/.test(source)) {
|
|
source = source.replace(',', '.');
|
|
} else {
|
|
return null;
|
|
}
|
|
const numeric = Number(source);
|
|
return Number.isFinite(numeric) ? numeric : null;
|
|
}
|
|
|
|
function numericColumnIndexes(sheet) {
|
|
if (!sheet) return [];
|
|
return sheet.analysis.headers
|
|
.map((_, index) => index)
|
|
.filter(index => sheet.analysis.records.some(record => parseNumericValue(record.fields[index]?.value) !== null));
|
|
}
|
|
|
|
function filteredRows(sheet) {
|
|
return filteredRecords(sheet);
|
|
}
|
|
|
|
function renderColumnFilter(sheet) {
|
|
let current = state.columnFilter;
|
|
const currentTitle = state.titleColumn;
|
|
const numericIndexes = numericColumnIndexes(sheet);
|
|
if (current !== 'all' && /^\d+$/.test(current) && sheet?.analysis.headers[Number(current)]) {
|
|
current = sheet.analysis.headers[Number(current)];
|
|
state.columnFilter = current;
|
|
}
|
|
const numericHeaders = numericIndexes.map(index => sheet.analysis.headers[index]);
|
|
const options = ['<option value="all">Keine numerische Sortierung</option>'];
|
|
const titleOptions = ['<option value="">Titel (auto)</option>'];
|
|
if (sheet) {
|
|
sheet.analysis.headers.forEach((header, index) => {
|
|
if (numericIndexes.includes(index)) options.push(`<option value="${escapeHtml(header)}">${escapeHtml(header)}</option>`);
|
|
titleOptions.push(`<option value="${escapeHtml(header)}">${escapeHtml(header)}</option>`);
|
|
});
|
|
}
|
|
const sortAvailable = current === 'all' || numericHeaders.includes(current);
|
|
if (current !== 'all' && !sortAvailable) options.push(`<option value="${escapeHtml(current)}">${escapeHtml(current)} (hier nicht verf\u00fcgbar)</option>`);
|
|
el.columnFilterSelect.innerHTML = options.join('');
|
|
el.titleColumnSelect.innerHTML = titleOptions.join('');
|
|
el.columnFilterSelect.value = current;
|
|
const titleValid = currentTitle === '' || (sheet && sheet.analysis.headers.includes(currentTitle));
|
|
state.titleColumn = titleValid ? currentTitle : '';
|
|
el.titleColumnSelect.value = state.titleColumn;
|
|
renderSortDirectionButton(sortAvailable);
|
|
}
|
|
|
|
function renderPurpleKnightFilter() {
|
|
const available = state.scanTool === 'PurpleKnight';
|
|
el.purpleKnightFilterSelect.hidden = !available;
|
|
el.purpleKnightFilterSelect.disabled = !available;
|
|
el.purpleKnightFilterSelect.closest('.search-tools')?.classList.toggle('has-purple-knight-filter', available);
|
|
if (!available) state.purpleKnightFilter = 'all';
|
|
el.purpleKnightFilterSelect.value = state.purpleKnightFilter;
|
|
}
|
|
function renderSortDirectionButton(sortAvailable = true) {
|
|
const ascending = state.sortDirection === 'asc';
|
|
const label = ascending ? 'Aufsteigend sortieren' : 'Absteigend sortieren';
|
|
el.sortDirectionBtn.textContent = ascending ? '↑' : '↓';
|
|
el.sortDirectionBtn.title = label;
|
|
el.sortDirectionBtn.setAttribute('aria-label', label);
|
|
el.sortDirectionBtn.disabled = state.columnFilter === 'all' || !sortAvailable;
|
|
}
|
|
|
|
function activeColumnIndexes(sheet) {
|
|
return sheet ? sheet.analysis.headers.map((_, index) => index) : [];
|
|
}
|
|
|
|
function activeColumns(sheet) {
|
|
return activeColumnIndexes(sheet).map(index => sheet.analysis.columns[index]).filter(Boolean);
|
|
}
|
|
|
|
function displayColumnIndexes(sheet) {
|
|
return sheet ? sheet.analysis.headers.map((_, index) => index) : [];
|
|
}
|
|
|
|
|
|
function activeFields(record) {
|
|
return record.fields;
|
|
}
|
|
|
|
function displayFields(record) {
|
|
return record.fields;
|
|
}
|
|
|
|
function visibleMeta(record) {
|
|
return record.meta;
|
|
}
|
|
|
|
function columnMatchesSearch(column) {
|
|
if (!state.search) return true;
|
|
return column.header.toLowerCase().includes(state.search) || column.topValues.some(item => item.value.toLowerCase().includes(state.search));
|
|
}
|
|
|
|
function hasActiveFilter() {
|
|
return Boolean(state.search) || state.columnFilter !== 'all' || state.purpleKnightFilter !== 'all';
|
|
}
|
|
|
|
function reviewStorageKey(file) {
|
|
if (!file) return '';
|
|
return `exxTableReader:actionlist:${file.name}:${file.size}:${file.lastModified || 0}`;
|
|
}
|
|
|
|
function loadReviews() {
|
|
if (!state.reviewStoreKey) return {};
|
|
try { return JSON.parse(localStorage.getItem(state.reviewStoreKey) || '{}'); } catch { return {}; }
|
|
}
|
|
|
|
function saveReviews() {
|
|
if (!state.reviewStoreKey) return;
|
|
try { localStorage.setItem(state.reviewStoreKey, JSON.stringify(state.reviews)); } catch {}
|
|
}
|
|
|
|
function reviewKey(record) {
|
|
const sheet = activeSheet();
|
|
return `${state.activeSheetIndex}:${sheet?.name || 'sheet'}:${record.index}:${record.title}`;
|
|
}
|
|
|
|
function reviewFor(record) {
|
|
const key = reviewKey(record);
|
|
return { ...defaultReview(), ...(state.reviews[key] || {}) };
|
|
}
|
|
|
|
function updateReviewField(key, field, value) {
|
|
state.reviews[key] = { ...defaultReview(), ...(state.reviews[key] || {}), [field]: value };
|
|
saveReviews();
|
|
renderActionSummary();
|
|
}
|
|
|
|
function actionRecords() {
|
|
return state.sheets.flatMap((sheet, sheetIndex) => sheet.analysis.records.map(record => ({ sheet, sheetIndex, record }))).filter(item => {
|
|
const key = `${item.sheetIndex}:${item.sheet.name}:${item.record.index}:${item.record.title}`;
|
|
return state.reviews[key]?.include;
|
|
});
|
|
}
|
|
|
|
function renderActionSummary() {
|
|
if (!el.actionCount) return;
|
|
const markedCount = actionRecords().length;
|
|
if (!markedCount) state.actionOnly = false;
|
|
const formatted = markedCount.toLocaleString('de-DE');
|
|
el.actionCount.textContent = formatted;
|
|
el.markedHeaderCount.textContent = formatted;
|
|
el.actionOnlyToggle.checked = state.actionOnly;
|
|
el.myWorkspaceBtn.disabled = markedCount === 0;
|
|
el.myWorkspaceBtn.classList.toggle('has-items', markedCount > 0);
|
|
el.myWorkspaceBtn.classList.toggle('is-active', state.actionOnly && markedCount > 0);
|
|
el.myWorkspaceBtn.setAttribute('aria-pressed', String(state.actionOnly && markedCount > 0));
|
|
el.myWorkspaceBtn.title = state.actionOnly ? 'Alle Datensätze anzeigen' : 'Nur markierte Datensätze anzeigen';
|
|
}
|
|
|
|
function renderMarkdown(value) {
|
|
const source = String(value || '').replace(/\r\n?/g, '\n').trim();
|
|
if (!source) return '';
|
|
const lines = source.split('\n');
|
|
const blocks = [];
|
|
let index = 0;
|
|
while (index < lines.length) {
|
|
if (!lines[index].trim()) { index += 1; continue; }
|
|
if (isMarkdownTableStart(lines, index)) {
|
|
const header = splitMarkdownRow(lines[index]);
|
|
index += 2;
|
|
const rows = [];
|
|
while (index < lines.length && splitMarkdownRow(lines[index]).length) {
|
|
rows.push(splitMarkdownRow(lines[index]));
|
|
index += 1;
|
|
}
|
|
blocks.push('<table><thead><tr>' + header.map(cell => '<th>' + renderMarkdownInline(cell) + '</th>').join('') + '</tr></thead><tbody>' + rows.map(row => '<tr>' + header.map((_, cellIndex) => '<td>' + renderMarkdownInline(row[cellIndex] || '') + '</td>').join('') + '</tr>').join('') + '</tbody></table>');
|
|
continue;
|
|
}
|
|
if (/^\s*[-*+]\s+/.test(lines[index])) {
|
|
const items = [];
|
|
while (index < lines.length && /^\s*[-*+]\s+/.test(lines[index])) {
|
|
items.push(lines[index].replace(/^\s*[-*+]\s+/, ''));
|
|
index += 1;
|
|
}
|
|
blocks.push('<ul>' + items.map(item => '<li>' + renderMarkdownInline(item) + '</li>').join('') + '</ul>');
|
|
continue;
|
|
}
|
|
if (/^\s*\d+[.)]\s+/.test(lines[index])) {
|
|
const items = [];
|
|
while (index < lines.length && /^\s*\d+[.)]\s+/.test(lines[index])) {
|
|
items.push(lines[index].replace(/^\s*\d+[.)]\s+/, ''));
|
|
index += 1;
|
|
}
|
|
blocks.push('<ol>' + items.map(item => '<li>' + renderMarkdownInline(item) + '</li>').join('') + '</ol>');
|
|
continue;
|
|
}
|
|
const paragraph = [];
|
|
while (index < lines.length && lines[index].trim() && !isMarkdownTableStart(lines, index) && !/^\s*[-*+]\s+/.test(lines[index]) && !/^\s*\d+[.)]\s+/.test(lines[index])) {
|
|
paragraph.push(lines[index]);
|
|
index += 1;
|
|
}
|
|
blocks.push('<p>' + paragraph.map(renderMarkdownInline).join('<br>') + '</p>');
|
|
}
|
|
return blocks.join('');
|
|
}
|
|
|
|
function isMarkdownTableStart(lines, index) {
|
|
return splitMarkdownRow(lines[index]).length > 1 && index + 1 < lines.length && isMarkdownSeparatorLine(lines[index + 1]);
|
|
}
|
|
|
|
function splitMarkdownRow(line) {
|
|
const trimmed = String(line || '').trim();
|
|
if (!trimmed.includes('|')) return [];
|
|
const row = trimmed.replace(/^\|/, '').replace(/\|$/, '');
|
|
return row.split('|').map(cell => cell.trim());
|
|
}
|
|
|
|
function isMarkdownSeparatorLine(line) {
|
|
const cells = splitMarkdownRow(line);
|
|
return cells.length > 1 && cells.every(cell => /^:?-{3,}:?$/.test(cell.replace(/\s+/g, '')) || /^-+$/.test(cell.replace(/\s+/g, '')));
|
|
}
|
|
|
|
function renderMarkdownInline(value) {
|
|
let html = escapeHtml(value || '');
|
|
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
html = html.replace(/__([^_]+)__/g, '<strong>$1</strong>');
|
|
html = html.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
|
|
html = html.replace(/(^|[^_])_([^_]+)_/g, '$1<em>$2</em>');
|
|
return html;
|
|
}
|
|
function actionExportRows() {
|
|
return actionRecords().map(item => {
|
|
const key = `${item.sheetIndex}:${item.sheet.name}:${item.record.index}:${item.record.title}`;
|
|
const review = { ...defaultReview(), ...(state.reviews[key] || {}) };
|
|
const model = readerModel(item.record);
|
|
return {
|
|
Blatt: item.sheet.name, Datensatz: item.record.index + 1, Finding: plainTextValue(item.record.title), 'Kritikalität': model.severity, Quelle: model.source, Gruppe: model.group,
|
|
Summary: model.summary.map(field => `${field.label}: ${plainTextValue(field.value)}`).join(' | '), Originaldaten: displayFields(item.record).filter(field => field.value).map(field => `${field.label}: ${plainTextValue(field.value)}`).join(' | '), Status: review.status, 'Priorität': review.priority, Owner: review.owner, Frist: review.due,
|
|
Voraussetzungen: review.prerequisites, Zugriffsrechte: review.access, 'NächsterSchritt': review.nextStep, Nachweis: review.evidence, Kommentar: review.comment, Markierungen: reviewMarkers(review).join(', ')
|
|
};
|
|
});
|
|
}
|
|
|
|
function reviewMarkers(review) {
|
|
const labels = [];
|
|
if (review.marker_question) labels.push('Rückfrage nötig');
|
|
if (review.marker_blocked) labels.push('Berechtigung blockiert');
|
|
if (review.marker_quickwin) labels.push('Quick Win');
|
|
if (review.marker_external) labels.push('Externes Team');
|
|
if (review.marker_notrelevant) labels.push('Nicht relevant');
|
|
return labels;
|
|
}
|
|
|
|
function exportActionList(format) {
|
|
try {
|
|
const rows = actionExportRows();
|
|
if (!rows.length) { setStatus('Keine Actionlist'); return; }
|
|
const name = `${baseName(state.file?.name || 'actionlist')}-actionlist`;
|
|
if (format === 'md') return downloadText(`${name}.md`, toMarkdown(rows), 'text/markdown;charset=utf-8', 'Markdown exportiert');
|
|
if (format === 'html') return downloadText(`${name}.html`, toActionHtml(rows), 'text/html;charset=utf-8', 'HTML-Dashboard exportiert');
|
|
return downloadText(`${name}.csv`, '\ufeff' + objectsToCsv(rows), 'text/csv;charset=utf-8', 'CSV exportiert');
|
|
} catch (error) {
|
|
console.error(error);
|
|
setStatus('Export fehlgeschlagen');
|
|
}
|
|
}
|
|
async function saveTextAs(filename, text, type, status = 'Gespeichert') {
|
|
if (window.showSaveFilePicker) {
|
|
try {
|
|
const handle = await window.showSaveFilePicker({
|
|
suggestedName: filename,
|
|
types: [{ description: 'HTML-Datei', accept: { 'text/html': ['.html'] } }]
|
|
});
|
|
const writable = await handle.createWritable();
|
|
await writable.write(new Blob([text], { type }));
|
|
await writable.close();
|
|
setStatus(status);
|
|
return;
|
|
} catch (error) {
|
|
if (error?.name === 'AbortError') {
|
|
setStatus('Speichern abgebrochen');
|
|
return;
|
|
}
|
|
console.warn(error);
|
|
}
|
|
}
|
|
downloadText(filename, text, type, status);
|
|
}
|
|
function downloadText(filename, text, type, status = 'Actionlist exportiert') {
|
|
const blob = new Blob([text], { type });
|
|
const link = document.createElement('a');
|
|
link.href = URL.createObjectURL(blob);
|
|
link.download = filename;
|
|
link.style.display = 'none';
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
setTimeout(() => URL.revokeObjectURL(link.href), 0);
|
|
link.remove();
|
|
setStatus(status);
|
|
}
|
|
function objectsToCsv(rows) {
|
|
const headers = Object.keys(rows[0] || {});
|
|
return [headers, ...rows.map(row => headers.map(header => row[header]))].map(row => row.map(value => {
|
|
const cell = stringifyCell(value);
|
|
return /[",\n\r;]/.test(cell) ? `"${cell.replaceAll('"', '""')}"` : cell;
|
|
}).join(';')).join('\n');
|
|
}
|
|
|
|
function toMarkdown(rows) {
|
|
return rows.map((row, index) => `## ${index + 1}. ${row.Finding}\n\n- Status: ${row.Status}\n- Priorität: ${row['Priorität']}\n- Owner: ${row.Owner || '-'}\n- Frist: ${row.Frist || '-'}\n- Kritikalität: ${row['Kritikalität']}\n- Voraussetzungen: ${row.Voraussetzungen || '-'}\n- Zugriffsrechte: ${row.Zugriffsrechte || '-'}\n- Nächster Schritt: ${row['NächsterSchritt'] || '-'}\n- Nachweis: ${row.Nachweis || '-'}\n- Markierungen: ${row.Markierungen || '-'}\n\n${row.Summary}\n\n${row.Kommentar || ''}`).join('\n\n---\n\n');
|
|
}
|
|
|
|
function toActionHtml(rows) {
|
|
const sourceName = state.file?.name || 'Actionlist';
|
|
const generatedAt = new Date().toLocaleString('de-DE');
|
|
const countBy = (items, getter) => items.reduce((counts, item) => {
|
|
const key = String(getter(item) || '-').trim() || '-';
|
|
counts[key] = (counts[key] || 0) + 1;
|
|
return counts;
|
|
}, {});
|
|
const normalizeSeverity = value => {
|
|
const text = String(value || '').trim();
|
|
const lower = text.toLowerCase();
|
|
if (lower.includes('critical') || lower.includes('kritisch')) return 'Critical';
|
|
if (lower.includes('high') || lower.includes('hoch')) return 'High';
|
|
if (lower.includes('medium') || lower.includes('mittel')) return 'Medium';
|
|
if (lower.includes('low') || lower.includes('niedrig')) return 'Low';
|
|
if (lower.includes('info')) return 'Info';
|
|
return text || '-';
|
|
};
|
|
const pillClass = value => {
|
|
const lower = String(value || '').toLowerCase();
|
|
if (lower.includes('critical') || lower.includes('kritisch') || lower.includes('hoch') || lower.includes('high') || lower.includes('offen')) return 'red';
|
|
if (lower.includes('mittel') || lower.includes('medium') || lower.includes('progress') || lower.includes('bearbeitung')) return 'orange';
|
|
if (lower.includes('low') || lower.includes('niedrig') || lower.includes('done') || lower.includes('erledigt') || lower.includes('behoben')) return 'green';
|
|
return 'blue';
|
|
};
|
|
const pill = value => `<span class="status-pill ${pillClass(value)}">${escapeHtml(value || '-')}</span>`;
|
|
const sortedEntries = counts => Object.entries(counts).sort((a, b) => b[1] - a[1]);
|
|
const bars = counts => sortedEntries(counts).map(([label, count]) => {
|
|
const width = Math.max(5, Math.round(count / Math.max(rows.length, 1) * 100));
|
|
return `<div class="bar-row"><span>${escapeHtml(label)}</span><div class="bar ${pillClass(label)}"><span style="width:${width}%"></span></div><strong>${count}</strong></div>`;
|
|
}).join('') || '<p class="muted">Keine Daten.</p>';
|
|
const summaryRows = counts => sortedEntries(counts).slice(0, 8).map(([label, count]) => `<tr><td>${escapeHtml(label)}</td><td><strong>${count}</strong></td></tr>`).join('');
|
|
const severityCounts = countBy(rows, row => normalizeSeverity(row['Kritikalität']));
|
|
const statusCounts = countBy(rows, row => row.Status || 'Offen');
|
|
const priorityCounts = countBy(rows, row => row['Priorität'] || 'Mittel');
|
|
const ownerCounts = countBy(rows, row => row.Owner || 'Nicht zugeordnet');
|
|
const criticalHigh = rows.filter(row => ['critical', 'high'].includes(normalizeSeverity(row['Kritikalität']).toLowerCase())).length;
|
|
const quickWins = rows.filter(row => String(row.Markierungen || '').includes('Quick Win'));
|
|
const recommendationRows = (quickWins.length ? quickWins : rows).slice(0, 6);
|
|
const roadmapRows = rows.filter(row => row['NächsterSchritt']).slice(0, 9);
|
|
const findingRows = rows.map(row => `
|
|
<tr>
|
|
<td><strong>${escapeHtml(row.Datensatz)}</strong><span>${escapeHtml(row.Blatt)}</span></td>
|
|
<td><strong>${escapeHtml(row.Finding)}</strong><span>${escapeHtml(row.Summary || row.Originaldaten || '-')}</span></td>
|
|
<td>${pill(normalizeSeverity(row['Kritikalität']))}</td>
|
|
<td>${escapeHtml(row.Gruppe || '-')}</td>
|
|
<td>${pill(row.Status || 'Offen')}</td>
|
|
<td>${escapeHtml(row.Owner || '-')}</td>
|
|
<td>${escapeHtml(row.Frist || '-')}</td>
|
|
<td>${escapeHtml(row['NächsterSchritt'] || row.Kommentar || '-')}</td>
|
|
</tr>`).join('');
|
|
return `<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Management Dashboard | ${escapeHtml(baseName(sourceName))}</title>
|
|
<style>
|
|
:root{--shell:#3f3d3b;--nav:#f3f2f1;--page:#faf9f8;--panel:#fff;--line:#edebe9;--line-strong:#d2d0ce;--text:#201f1e;--muted:#605e5c;--blue:#0078d4;--red:#d13438;--red-soft:#fde7e9;--orange:#ca5010;--orange-soft:#fff4ce;--green:#107c10;--green-soft:#dff6dd;--font:"Segoe UI",Arial,sans-serif}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--page);color:var(--text);font-family:var(--font);font-size:14px}.topbar{height:48px;background:var(--shell);color:#fff;display:flex;align-items:center;justify-content:center;padding:0 14px;position:sticky;top:0;z-index:10}.search{width:430px;max-width:calc(100vw - 28px);height:32px;background:#fff;border:1px solid #c8c6c4;border-radius:2px;display:flex;align-items:center;gap:8px;color:var(--muted);padding:0 10px}.search input{width:100%;border:0;outline:0}.layout{display:grid;grid-template-columns:248px minmax(0,1fr);min-height:calc(100vh - 48px)}.sidebar{background:var(--nav);border-right:1px solid var(--line);padding:8px 0 20px;position:sticky;top:48px;height:calc(100vh - 48px);overflow:auto}.nav-row{min-height:34px;display:flex;align-items:center;gap:10px;padding:0 16px;color:var(--text);text-decoration:none;border-left:3px solid transparent}.nav-row:hover{background:#edebe9}.nav-row.active{background:#fff;border-left-color:var(--blue);font-weight:600}.nav-icon{width:18px;color:var(--muted);text-align:center}.main{padding:28px 36px 48px;max-width:1680px;min-width:0}.headline{display:flex;justify-content:space-between;align-items:flex-end;gap:24px;margin-bottom:18px}h1{margin:0;font-size:28px;line-height:1.2}.subtitle,.muted{color:var(--muted)}.commandbar{display:flex;gap:8px;flex-wrap:wrap}.btn{border:1px solid var(--line-strong);background:#fff;color:var(--text);padding:7px 12px;border-radius:2px;text-decoration:none;cursor:pointer}.btn.primary{background:var(--blue);border-color:var(--blue);color:#fff}.notice{display:flex;justify-content:space-between;gap:18px;align-items:center;background:#fff4ce;border-left:4px solid var(--orange);padding:14px 16px;margin-bottom:14px}.grid{display:grid;gap:14px}.kpis{grid-template-columns:repeat(5,minmax(150px,1fr))}.cards-3{grid-template-columns:repeat(3,minmax(0,1fr))}.cards-2{grid-template-columns:repeat(2,minmax(0,1fr))}.panel{background:var(--panel);border:1px solid var(--line);box-shadow:0 1px 2px rgba(0,0,0,.08)}.panel-head{min-height:48px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 16px}.panel-title{font-size:16px;margin:0}.panel-body{padding:16px}.kpi{padding:16px;min-height:126px;display:flex;flex-direction:column;justify-content:space-between}.kpi-label{color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.04em}.kpi-value{font-size:34px;font-weight:700;margin-top:6px}.kpi-detail{color:var(--muted);font-size:13px}.status-pill{display:inline-flex;align-items:center;border-radius:999px;padding:3px 8px;font-size:12px;font-weight:600;background:#eff6fc;color:var(--blue);white-space:nowrap}.status-pill.red{background:var(--red-soft);color:var(--red)}.status-pill.orange{background:var(--orange-soft);color:var(--orange)}.status-pill.green{background:var(--green-soft);color:var(--green)}.insight-list{list-style:none;padding:0;margin:0;display:grid;gap:12px}.insight{display:grid;grid-template-columns:28px minmax(0,1fr) auto;gap:10px;align-items:start}.insight .num{background:#edebe9;border-radius:50%;width:28px;height:28px;display:grid;place-items:center;font-weight:700}.insight h3{margin:0 0 4px;font-size:14px}.insight p{margin:0;color:var(--muted)}.timeline{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.phase{border-left:4px solid var(--blue);background:#faf9f8;padding:12px}.phase h3{margin:0 0 8px;font-size:15px}.phase ul{margin:0;padding-left:18px}.bar-row{display:grid;grid-template-columns:120px 1fr 42px;align-items:center;gap:10px;margin:8px 0}.bar{height:8px;background:#edebe9;overflow:hidden}.bar span{display:block;height:100%;background:var(--blue)}.bar.red span{background:var(--red)}.bar.orange span{background:var(--orange)}.bar.green span{background:var(--green)}table{width:100%;border-collapse:collapse;table-layout:auto}th,td{border-bottom:1px solid var(--line);padding:10px 8px;text-align:left;vertical-align:top}th{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.03em;background:#faf9f8}td span{display:block;color:var(--muted);font-size:12px;margin-top:4px}.findings-table-scroll{overflow:auto}.findings-table th:nth-child(2),.findings-table td:nth-child(2){min-width:360px}.footer{margin-top:18px;color:var(--muted);font-size:12px}@media(max-width:1100px){.layout{grid-template-columns:1fr}.sidebar{display:none}.main{padding:20px}.kpis,.cards-3,.cards-2,.timeline{grid-template-columns:1fr}.headline,.notice{align-items:flex-start;flex-direction:column}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header class="topbar"><label class="search" aria-label="Dashboard Suche"><span aria-hidden="true">⌕</span><input id="globalSearch" type="search" placeholder="Search findings, owners, actions"></label></header>
|
|
<div class="layout"><aside class="sidebar" aria-label="Navigation"><a class="nav-row active" href="#overview"><span class="nav-icon">▒</span>Dashboard</a><a class="nav-row" href="#quickwins"><span class="nav-icon">✓</span>Recommendations</a><a class="nav-row" href="#roadmap"><span class="nav-icon">↻</span>Remediation</a><a class="nav-row" href="#findings"><span class="nav-icon">☰</span>Inventories</a></aside>
|
|
<main class="main">
|
|
<section id="overview" class="headline"><div><h1>Management Dashboard - ${escapeHtml(baseName(sourceName))}</h1><div class="subtitle">Actionlist Export | generiert am ${escapeHtml(generatedAt)}</div></div><div class="commandbar"><a class="btn" href="#findings">📄 Findings</a><a class="btn primary" href="#roadmap">→ Next steps</a></div></section>
|
|
<div class="notice"><span><strong>Executive Summary:</strong> ${rows.length.toLocaleString('de-DE')} Actionlist-Einträge, davon ${criticalHigh.toLocaleString('de-DE')} mit Critical/High-Priorität. Fokus: klare Owner, Fristen und nächste Schritte für die Umsetzung.</span><a class="btn" href="#findings">Show details</a></div>
|
|
<section class="grid kpis" aria-label="Management KPIs"><article class="panel kpi"><div><div class="kpi-label">Actionlist</div><div class="kpi-value">${rows.length.toLocaleString('de-DE')}</div></div><div class="kpi-detail">markierte Datensätze</div></article><article class="panel kpi"><div><div class="kpi-label">Critical / High</div><div class="kpi-value" style="color:var(--red)">${criticalHigh.toLocaleString('de-DE')}</div></div><div class="kpi-detail">Management-Fokus</div></article><article class="panel kpi"><div><div class="kpi-label">Quick Wins</div><div class="kpi-value" style="color:var(--green)">${quickWins.length.toLocaleString('de-DE')}</div></div><div class="kpi-detail">entsprechend Markierung</div></article><article class="panel kpi"><div><div class="kpi-label">Ohne Owner</div><div class="kpi-value" style="color:var(--orange)">${rows.filter(row => !row.Owner).length.toLocaleString('de-DE')}</div></div><div class="kpi-detail">nachsteuern</div></article><article class="panel kpi"><div><div class="kpi-label">Ohne Frist</div><div class="kpi-value">${rows.filter(row => !row.Frist).length.toLocaleString('de-DE')}</div></div><div class="kpi-detail">Terminierung offen</div></article></section>
|
|
<section id="risk" class="grid cards-3" style="margin-top:14px;"><article class="panel"><div class="panel-head"><h2 class="panel-title">Severity-Verteilung</h2>${pill(criticalHigh ? 'Action required' : 'Stabil')}</div><div class="panel-body">${bars(severityCounts)}</div></article><article class="panel"><div class="panel-head"><h2 class="panel-title">Status</h2></div><div class="panel-body"><table><tbody>${summaryRows(statusCounts)}</tbody></table></div></article><article class="panel"><div class="panel-head"><h2 class="panel-title">Prioritäten</h2></div><div class="panel-body"><table><tbody>${summaryRows(priorityCounts)}</tbody></table></div></article></section>
|
|
<section class="grid cards-2" style="margin-top:14px;"><article class="panel"><div class="panel-head"><h2 class="panel-title">Owner-Verteilung</h2><span class="muted">Top Owner</span></div><div class="panel-body"><table><thead><tr><th>Owner</th><th style="width:96px">Einträge</th></tr></thead><tbody>${summaryRows(ownerCounts)}</tbody></table></div></article><article class="panel"><div class="panel-head"><h2 class="panel-title">Top security recommendations</h2></div><div class="panel-body"><ul class="insight-list">${recommendationRows.slice(0, 3).map((row, index) => `<li class="insight"><span class="num">${index + 1}</span><div><h3>${escapeHtml(row.Finding)}</h3><p>${escapeHtml(row['NächsterSchritt'] || row.Voraussetzungen || row.Summary || '-')}</p></div>${pill(normalizeSeverity(row['Kritikalität']))}</li>`).join('')}</ul></div></article></section>
|
|
<section id="quickwins" class="panel" style="margin-top:14px;"><div class="panel-head"><h2 class="panel-title">Quick Wins mit Management-Wirkung</h2></div><div class="panel-body"><table><thead><tr><th>Quick Win</th><th>Warum relevant</th><th style="width:150px">Wirkung</th><th style="width:140px">Owner</th><th style="width:120px">Frist</th></tr></thead><tbody>${recommendationRows.map(row => `<tr><td><strong>${escapeHtml(row.Finding)}</strong></td><td>${escapeHtml(row['NächsterSchritt'] || row.Voraussetzungen || row.Kommentar || '-')}</td><td>${pill(normalizeSeverity(row['Kritikalität']))}</td><td>${escapeHtml(row.Owner || '-')}</td><td>${escapeHtml(row.Frist || '-')}</td></tr>`).join('')}</tbody></table></div></section>
|
|
<section id="roadmap" class="panel" style="margin-top:14px;"><div class="panel-head"><h2 class="panel-title">Next Steps - Remediation Roadmap</h2><span class="status-pill">Priorisiert nach Actionlist</span></div><div class="panel-body timeline">${['0-14 Tage | Stabilisieren', '15-30 Tage | Risiko reduzieren', '31-60 Tage | Nachhaltig verankern'].map((title, phaseIndex) => `<div class="phase"><h3>${title}</h3><ul>${(roadmapRows.length ? roadmapRows : rows.slice(0, 9)).filter((_, index) => index % 3 === phaseIndex).map(row => `<li>${escapeHtml(row['NächsterSchritt'] || row.Finding)}</li>`).join('') || '<li>Owner, Frist und Nachweis für offene Einträge ergänzen.</li>'}</ul></div>`).join('')}</div></section>
|
|
<section id="findings" class="panel" style="margin-top:14px;"><div class="panel-head"><h2 class="panel-title">Priorisierte Finding-Liste</h2><span id="findingCount" class="muted">${rows.length.toLocaleString('de-DE')} Findings</span></div><div class="panel-body"><div class="findings-table-scroll"><table class="findings-table"><thead><tr><th style="width:120px">Datensatz</th><th>Befund</th><th style="width:100px">Severity</th><th style="width:180px">Gruppe</th><th style="width:122px">Status</th><th style="width:140px">Owner</th><th style="width:120px">Frist</th><th style="width:260px">Aktion</th></tr></thead><tbody id="findingRows">${findingRows}</tbody></table></div></div></section>
|
|
<div class="footer">Quelle: ${escapeHtml(sourceName)}. Dieses Dashboard verdichtet die markierte Actionlist für Management-Kommunikation und ersetzt keine technische Detailanalyse.</div>
|
|
</main>
|
|
</div>
|
|
<script>const search=document.getElementById('globalSearch');const rowsNode=document.getElementById('findingRows');const originalRows=Array.from(rowsNode.querySelectorAll('tr'));search.addEventListener('input',()=>{const query=search.value.trim().toLowerCase();let visible=0;originalRows.forEach(row=>{const match=!query||row.textContent.toLowerCase().includes(query);row.hidden=!match;if(match)visible+=1;});document.getElementById('findingCount').textContent=visible.toLocaleString('de-DE')+' Findings';});<\/script>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
function activeSheet() {
|
|
return state.sheets[state.activeSheetIndex];
|
|
}
|
|
|
|
function updateMeta() {
|
|
el.fileName.textContent = state.file?.name || '-';
|
|
el.fileType.textContent = state.format || state.file?.type || '-';
|
|
el.fileSize.textContent = state.file ? formatBytes(state.file.size) : '-';
|
|
el.excelNotice.hidden = state.format !== 'Excel' || Boolean(window.XLSX);
|
|
}
|
|
|
|
function setStatus(status) {
|
|
el.fileStatus.textContent = status;
|
|
}
|
|
|
|
function formatBytes(bytes) {
|
|
if (!bytes) return '0 B';
|
|
const units = ['B', 'KB', 'MB', 'GB'];
|
|
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
|
return `${(bytes / Math.pow(1024, index)).toFixed(index ? 1 : 0)} ${units[index]}`;
|
|
}
|
|
|
|
function toCsv(rows) {
|
|
return rows.map(row => row.map(value => {
|
|
const cell = stringifyCell(value);
|
|
return /[",\n\r;]/.test(cell) ? `"${cell.replaceAll('"', '""')}"` : cell;
|
|
}).join(';')).join('\n');
|
|
}
|
|
|
|
function formatJson(value) {
|
|
return JSON.stringify(value, null, 2);
|
|
}
|
|
|
|
function visibleText() {
|
|
return el.viewerBody.innerText || '';
|
|
}
|
|
|
|
function baseName(name) {
|
|
return name.replace(/\.[^.]+$/, '');
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
|
}
|
|
|
|
function highlight(value) {
|
|
return renderSafeValue(value);
|
|
}
|
|
|
|
function renderSafeValue(value) {
|
|
const source = String(value ?? '');
|
|
if (!/<\/?[a-z][\s\S]*?>/i.test(source) || typeof DOMParser === 'undefined') {
|
|
return highlightTextSegment(source).replace(/\r?\n/g, '<br>');
|
|
}
|
|
const doc = new DOMParser().parseFromString(source, 'text/html');
|
|
const renderNode = node => {
|
|
if (node.nodeType === 3) return highlightTextSegment(node.nodeValue || '');
|
|
if (node.nodeType !== 1) return '';
|
|
const tag = node.tagName.toLowerCase();
|
|
if (['script', 'style', 'iframe', 'object', 'embed', 'svg'].includes(tag)) return '';
|
|
const content = [...node.childNodes].map(renderNode).join('');
|
|
if (tag === 'br') return '<br>';
|
|
if (['b', 'strong'].includes(tag)) return `<strong>${content}</strong>`;
|
|
if (['i', 'em'].includes(tag)) return `<em>${content}</em>`;
|
|
if (tag === 'u') return `<u>${content}</u>`;
|
|
if (tag === 'a') {
|
|
const href = node.getAttribute('href') || '';
|
|
return /^(https?:|mailto:)/i.test(href)
|
|
? `<a class="safe-value-link" href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${content}</a>`
|
|
: content;
|
|
}
|
|
if (tag === 'li') return `• ${content}<br>`;
|
|
if (/^h[1-6]$/.test(tag)) return `<strong>${content}</strong><br>`;
|
|
if (['p', 'div', 'section', 'article', 'blockquote', 'tr'].includes(tag)) return `${content}<br><br>`;
|
|
if (['td', 'th'].includes(tag)) return `${content} · `;
|
|
if (tag === 'img') return highlightTextSegment(node.getAttribute('alt') || '');
|
|
return content;
|
|
};
|
|
return [...doc.body.childNodes].map(renderNode).join('').replace(/(?:<br>\s*)+$/g, '');
|
|
}
|
|
|
|
function highlightTextSegment(value) {
|
|
const escaped = escapeHtml(value);
|
|
if (!state.search) return escaped;
|
|
const pattern = new RegExp(`(${escapeRegExp(state.search)})`, 'ig');
|
|
return escaped.replace(pattern, '<mark>$1</mark>');
|
|
}
|
|
|
|
function plainTextValue(value) {
|
|
const source = String(value ?? '');
|
|
if (!/<\/?[a-z][\s\S]*?>/i.test(source) || typeof DOMParser === 'undefined') return source;
|
|
const separated = source.replace(/<br\s*\/?\s*>/gi, ' ').replace(/<\/?(?:p|div|section|article|li|tr|h[1-6]|blockquote)[^>]*>/gi, ' ');
|
|
const doc = new DOMParser().parseFromString(separated, 'text/html');
|
|
return (doc.body.textContent || '').replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
function loadEmbeddedSnapshot() {
|
|
const node = document.getElementById('embeddedSnapshot');
|
|
if (!node) return;
|
|
try {
|
|
const snapshot = parseSnapshotPayload(node.textContent || '');
|
|
if (!snapshot) return;
|
|
const filename = `${baseName(snapshot.sourceFile?.name || 'daten')}-snapshot.html`;
|
|
loadSnapshot(snapshot, { name: filename, size: document.documentElement.outerHTML.length, type: 'Snapshot', lastModified: Date.parse(snapshot.createdAt) || Date.now() });
|
|
updateMeta();
|
|
render();
|
|
setStatus('Snapshot geladen');
|
|
} catch (error) {
|
|
console.error(error);
|
|
setStatus('Snapshot fehlerhaft');
|
|
}
|
|
}
|
|
function escapeRegExp(value) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
loadEmbeddedSnapshot();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|