Assessor_2.0/reference/exxTableReader-legacy.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 &ouml;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&auml;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&auml;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&auml;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 &middot; 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">&times;</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 &middot; 0</option></select>';
return;
}
const options = state.sheets.map((sheet, index) =>
`<option value="${index}" ${index === state.activeSheetIndex ? 'selected' : ''}>${escapeHtml(sheet.name)} &middot; ${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&uuml;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&uuml;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' : ''}>&lsaquo;</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' : ''}>&rsaquo;</button>
</div>
<div class="reader-crumbs">Inbox${contextLine.map(item => `&nbsp; / &nbsp;${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">&middot;</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)} &middot; ${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&auml;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">&times;</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">&#8981;</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">&#9618;</span>Dashboard</a><a class="nav-row" href="#quickwins"><span class="nav-icon">&#10003;</span>Recommendations</a><a class="nav-row" href="#roadmap"><span class="nav-icon">&#8635;</span>Remediation</a><a class="nav-row" href="#findings"><span class="nav-icon">&#9776;</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">&#128196; Findings</a><a class="btn primary" href="#roadmap">&#8594; 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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#039;');
}
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 `&bull; ${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} &middot; `;
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>