feat: add community publishing and news syndication
Validate and deploy production / validate (push) Successful in 23s
Validate and deploy staging / validate (push) Successful in 50s
Validate and deploy production / deploy (push) Failing after 40s
Validate and deploy staging / deploy (push) Failing after 7s

This commit is contained in:
jb
2026-08-01 21:42:09 -04:00
parent b47e2588e9
commit dd84dff055
288 changed files with 83254 additions and 38351 deletions
@@ -20,111 +20,467 @@
background: var(--canvas);
}
* { box-sizing: border-box; }
* {
box-sizing: border-box;
}
body { margin: 0; background: var(--canvas); color: var(--text); }
body {
margin: 0;
background: var(--canvas);
color: var(--text);
}
button, input { font: inherit; }
button,
input {
font: inherit;
}
button, input[type="radio"], input[type="checkbox"] { accent-color: var(--orange); }
button,
input[type='radio'],
input[type='checkbox'] {
accent-color: var(--orange);
}
button { min-height: 44px; border: 1px solid var(--line-strong); border-radius: 0; background: var(--surface); color: var(--text); cursor: pointer; padding: .65rem .9rem; font-weight: 700; }
button:hover:not(:disabled) { background: #1b2021; border-color: var(--text); }
button:active:not(:disabled) { transform: translateY(1px); }
button:disabled { color: var(--dim); cursor: not-allowed; border-color: var(--line); background: #101213; }
.button-primary { background: var(--orange); color: #12100b; border-color: var(--orange); }
.button-primary:hover:not(:disabled) { background: var(--orange-hover); border-color: var(--orange-hover); }
.button-danger { color: var(--danger); }
.quiet-danger { border-color: #6f4941; }
button {
min-height: 44px;
border: 1px solid var(--line-strong);
border-radius: 0;
background: var(--surface);
color: var(--text);
cursor: pointer;
padding: 0.65rem 0.9rem;
font-weight: 700;
}
button:hover:not(:disabled) {
background: #1b2021;
border-color: var(--text);
}
button:active:not(:disabled) {
transform: translateY(1px);
}
button:disabled {
color: var(--dim);
cursor: not-allowed;
border-color: var(--line);
background: #101213;
}
.button-primary {
background: var(--orange);
color: #12100b;
border-color: var(--orange);
}
.button-primary:hover:not(:disabled) {
background: var(--orange-hover);
border-color: var(--orange-hover);
}
.button-danger {
color: var(--danger);
}
.quiet-danger {
border-color: #6f4941;
}
:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; }
:focus-visible {
outline: 3px solid var(--focus);
outline-offset: 3px;
}
.skip-link { position: fixed; z-index: 2; top: 0; left: .5rem; transform: translateY(-120%); background: var(--orange); color: #12100b; padding: .65rem; font-weight: 700; }
.skip-link:focus { transform: translateY(0); }
.skip-link {
position: fixed;
z-index: 2;
top: 0;
left: 0.5rem;
transform: translateY(-120%);
background: var(--orange);
color: #12100b;
padding: 0.65rem;
font-weight: 700;
}
.skip-link:focus {
transform: translateY(0);
}
.shell { width: min(100% - 2rem, 100rem); margin: 0 auto; padding: 1.5rem 0 3rem; }
.command-header, .control-strip, .panel-heading { display: flex; gap: var(--space); justify-content: space-between; align-items: center; }
.command-header { border-bottom: 1px solid var(--line); padding: 0 0 1.25rem; }
.kicker { margin: 0 0 .35rem; color: var(--muted); font-size: .74rem; font-weight: 800; letter-spacing: .08em; }
h1, h2, p { margin-top: 0; }
h1 { margin-bottom: 0; font-size: clamp(1.65rem, 3vw, 2.35rem); letter-spacing: -.025em; }
h2 { margin-bottom: 0; font-size: 1.15rem; }
.header-state { text-align: right; }
.header-state strong, #capture-state, #review-state { display: block; font-size: .88rem; font-weight: 800; letter-spacing: .04em; }
.header-state span, #queue-state, #capture-count, .field-help, .action-help, figcaption { color: var(--muted); font-size: .88rem; }
.shell {
width: min(100% - 2rem, 100rem);
margin: 0 auto;
padding: 1.5rem 0 3rem;
}
.command-header,
.control-strip,
.panel-heading {
display: flex;
gap: var(--space);
justify-content: space-between;
align-items: center;
}
.command-header {
border-bottom: 1px solid var(--line);
padding: 0 0 1.25rem;
}
.kicker {
margin: 0 0 0.35rem;
color: var(--muted);
font-size: 0.74rem;
font-weight: 800;
letter-spacing: 0.08em;
}
h1,
h2,
p {
margin-top: 0;
}
h1 {
margin-bottom: 0;
font-size: clamp(1.65rem, 3vw, 2.35rem);
letter-spacing: -0.025em;
}
h2 {
margin-bottom: 0;
font-size: 1.15rem;
}
.header-state {
text-align: right;
}
.header-state strong,
#capture-state,
#review-state {
display: block;
font-size: 0.88rem;
font-weight: 800;
letter-spacing: 0.04em;
}
.header-state span,
#queue-state,
#capture-count,
.field-help,
.action-help,
figcaption {
color: var(--muted);
font-size: 0.88rem;
}
.notice { display: flex; gap: .75rem; border: 1px solid var(--line); border-left: 2px solid var(--info); margin: 1rem 0; padding: .8rem 1rem; background: var(--well); }
.notice strong { color: var(--info); font-size: .78rem; letter-spacing: .06em; }
.notice[data-kind="success"] { border-left-color: var(--success); }
.notice[data-kind="success"] strong { color: var(--success); }
.notice[data-kind="error"], .notice[data-kind="conflict"] { border-left-color: var(--danger); }
.notice[data-kind="error"] strong, .notice[data-kind="conflict"] strong { color: var(--danger); }
.notice[data-kind="retained"] { border-left-color: var(--info); }
.notice[data-kind="retained"] strong { color: var(--info); }
.notice {
display: flex;
gap: 0.75rem;
border: 1px solid var(--line);
border-left: 2px solid var(--info);
margin: 1rem 0;
padding: 0.8rem 1rem;
background: var(--well);
}
.notice strong {
color: var(--info);
font-size: 0.78rem;
letter-spacing: 0.06em;
}
.notice[data-kind='success'] {
border-left-color: var(--success);
}
.notice[data-kind='success'] strong {
color: var(--success);
}
.notice[data-kind='error'],
.notice[data-kind='conflict'] {
border-left-color: var(--danger);
}
.notice[data-kind='error'] strong,
.notice[data-kind='conflict'] strong {
color: var(--danger);
}
.notice[data-kind='retained'] {
border-left-color: var(--info);
}
.notice[data-kind='retained'] strong {
color: var(--info);
}
.control-strip { border: 1px solid var(--line); background: var(--surface-raised); padding: 1rem; margin-bottom: 1rem; }
.control-strip p:last-child { margin: 0; }
.control-actions, .form-actions, .dialog-actions { display: flex; gap: .5rem; flex-wrap: wrap; }
.control-strip {
border: 1px solid var(--line);
background: var(--surface-raised);
padding: 1rem;
margin-bottom: 1rem;
}
.control-strip p:last-child {
margin: 0;
}
.control-actions,
.form-actions,
.dialog-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.workspace-grid { display: grid; grid-template-columns: minmax(17rem, 24rem) minmax(0, 1fr); border: 1px solid var(--line); background: var(--surface); }
.capture-panel { border-right: 1px solid var(--line); display: flex; flex-direction: column; height: min(42rem, calc(100vh - 10rem)); min-height: 24rem; min-width: 0; }
.capture-panel, .review-panel { padding: 1rem; }
.panel-heading { border-bottom: 1px solid var(--line); padding-bottom: .85rem; }
.queue-filter { display: flex; gap: .55rem; align-items: center; min-height: 44px; color: var(--muted); font-size: .88rem; }
.queue-filter input { inline-size: 1.1rem; block-size: 1.1rem; flex: 0 0 auto; }
.capture-scroll { min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; padding-right: .25rem; scrollbar-gutter: stable; }
.empty-copy, .review-empty { color: var(--muted); padding: 1rem 0; line-height: 1.5; }
.capture-list { margin: 0; padding: 0; list-style: none; }
.capture-list button { width: 100%; min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .25rem .6rem; margin-top: .55rem; text-align: left; background: var(--well); }
.capture-list button[aria-current="true"] { border-color: var(--orange); color: var(--text); }
.capture-list .capture-id { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.capture-list .capture-meta { display: grid; gap: .15rem; color: var(--muted); font-size: .72rem; line-height: 1.2; text-align: right; white-space: nowrap; }
.capture-list .capture-review { color: var(--text); font-size: .75rem; font-weight: 800; letter-spacing: .04em; }
.capture-list .capture-time { overflow: hidden; text-overflow: ellipsis; }
.workspace-grid {
display: grid;
grid-template-columns: minmax(17rem, 24rem) minmax(0, 1fr);
border: 1px solid var(--line);
background: var(--surface);
}
.capture-panel {
border-right: 1px solid var(--line);
display: flex;
flex-direction: column;
height: min(42rem, calc(100vh - 10rem));
min-height: 24rem;
min-width: 0;
}
.capture-panel,
.review-panel {
padding: 1rem;
}
.panel-heading {
border-bottom: 1px solid var(--line);
padding-bottom: 0.85rem;
}
.queue-filter {
display: flex;
gap: 0.55rem;
align-items: center;
min-height: 44px;
color: var(--muted);
font-size: 0.88rem;
}
.queue-filter input {
inline-size: 1.1rem;
block-size: 1.1rem;
flex: 0 0 auto;
}
.capture-scroll {
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
padding-right: 0.25rem;
scrollbar-gutter: stable;
}
.empty-copy,
.review-empty {
color: var(--muted);
padding: 1rem 0;
line-height: 1.5;
}
.capture-list {
margin: 0;
padding: 0;
list-style: none;
}
.capture-list button {
width: 100%;
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.25rem 0.6rem;
margin-top: 0.55rem;
text-align: left;
background: var(--well);
}
.capture-list button[aria-current='true'] {
border-color: var(--orange);
color: var(--text);
}
.capture-list .capture-id {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.capture-list .capture-meta {
display: grid;
gap: 0.15rem;
color: var(--muted);
font-size: 0.72rem;
line-height: 1.2;
text-align: right;
white-space: nowrap;
}
.capture-list .capture-review {
color: var(--text);
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.04em;
}
.capture-list .capture-time {
overflow: hidden;
text-overflow: ellipsis;
}
.review-content { padding-top: 1rem; }
.evidence-frame { margin: 0 0 1rem; border: 1px solid var(--line); background: var(--well); padding: .65rem; }
.evidence-frame img { display: block; width: 100%; max-height: 31rem; object-fit: contain; background: #000; }
.evidence-frame figcaption { padding-top: .65rem; overflow-wrap: anywhere; }
fieldset { margin: 0 0 1rem; padding: .75rem; border: 1px solid var(--line); min-width: 0; }
fieldset:disabled { opacity: .65; }
legend { padding: 0 .35rem; font-weight: 700; }
.choice-grid { display: flex; flex-wrap: wrap; gap: .45rem; }
.choice { position: relative; display: inline-flex; min-height: 44px; align-items: center; border: 1px solid var(--line-strong); background: var(--well); cursor: pointer; }
.choice:hover { border-color: var(--text); }
.choice input { position: absolute; opacity: 0; inset: 0; cursor: pointer; }
.choice span { padding: .65rem .75rem; }
.choice input:checked + span { background: #3c2a0c; color: var(--text); box-shadow: inset 0 -2px var(--orange); }
.choice input:focus-visible + span { outline: 3px solid var(--focus); outline-offset: 2px; }
.validation-message { min-height: 1.5rem; margin: -.35rem 0 .75rem; color: var(--danger); }
.action-help { max-width: 70ch; margin: .8rem 0 0; line-height: 1.45; }
.review-content {
padding-top: 1rem;
}
.evidence-frame {
margin: 0 0 1rem;
border: 1px solid var(--line);
background: var(--well);
padding: 0.65rem;
}
.evidence-frame img {
display: block;
width: 100%;
max-height: 31rem;
object-fit: contain;
background: #000;
}
.evidence-frame figcaption {
padding-top: 0.65rem;
overflow-wrap: anywhere;
}
fieldset {
margin: 0 0 1rem;
padding: 0.75rem;
border: 1px solid var(--line);
min-width: 0;
}
fieldset:disabled {
opacity: 0.65;
}
legend {
padding: 0 0.35rem;
font-weight: 700;
}
.choice-grid {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.choice {
position: relative;
display: inline-flex;
min-height: 44px;
align-items: center;
border: 1px solid var(--line-strong);
background: var(--well);
cursor: pointer;
}
.choice:hover {
border-color: var(--text);
}
.choice input {
position: absolute;
opacity: 0;
inset: 0;
cursor: pointer;
}
.choice span {
padding: 0.65rem 0.75rem;
}
.choice input:checked + span {
background: #3c2a0c;
color: var(--text);
box-shadow: inset 0 -2px var(--orange);
}
.choice input:focus-visible + span {
outline: 3px solid var(--focus);
outline-offset: 2px;
}
.validation-message {
min-height: 1.5rem;
margin: -0.35rem 0 0.75rem;
color: var(--danger);
}
.action-help {
max-width: 70ch;
margin: 0.8rem 0 0;
line-height: 1.45;
}
dialog { width: min(calc(100% - 2rem), 34rem); border: 1px solid var(--orange); border-radius: 0; padding: 1.3rem; color: var(--text); background: var(--surface-raised); box-shadow: 0 18px 60px #000; }
dialog::backdrop { background: rgb(0 0 0 / 72%); }
dialog p { line-height: 1.5; }
dialog {
width: min(calc(100% - 2rem), 34rem);
border: 1px solid var(--orange);
border-radius: 0;
padding: 1.3rem;
color: var(--text);
background: var(--surface-raised);
box-shadow: 0 18px 60px #000;
}
dialog::backdrop {
background: rgb(0 0 0 / 72%);
}
dialog p {
line-height: 1.5;
}
@media (max-width: 52rem) {
.shell { width: min(100% - 1rem, 100rem); padding-top: 1rem; }
.command-header, .control-strip { align-items: flex-start; flex-direction: column; }
.header-state { text-align: left; }
.workspace-grid { grid-template-columns: 1fr; }
.capture-panel { height: auto; min-height: 0; border-right: 0; border-bottom: 1px solid var(--line); }
.capture-scroll { overflow: visible; padding-right: 0; scrollbar-gutter: auto; }
.capture-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: .55rem; }
.capture-list button { margin-top: 0; }
.shell {
width: min(100% - 1rem, 100rem);
padding-top: 1rem;
}
.command-header,
.control-strip {
align-items: flex-start;
flex-direction: column;
}
.header-state {
text-align: left;
}
.workspace-grid {
grid-template-columns: 1fr;
}
.capture-panel {
height: auto;
min-height: 0;
border-right: 0;
border-bottom: 1px solid var(--line);
}
.capture-scroll {
overflow: visible;
padding-right: 0;
scrollbar-gutter: auto;
}
.capture-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
gap: 0.55rem;
}
.capture-list button {
margin-top: 0;
}
}
@media (max-width: 33rem) {
.control-actions, .form-actions, .dialog-actions { display: grid; grid-template-columns: 1fr; width: 100%; }
.control-actions button, .form-actions button, .dialog-actions button { width: 100%; }
.notice { align-items: flex-start; flex-direction: column; gap: .25rem; }
.control-actions,
.form-actions,
.dialog-actions {
display: grid;
grid-template-columns: 1fr;
width: 100%;
}
.control-actions button,
.form-actions button,
.dialog-actions button {
width: 100%;
}
.notice {
align-items: flex-start;
flex-direction: column;
gap: 0.25rem;
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
@media (forced-colors: active) {
button, fieldset, .choice, .notice, .workspace-grid, .evidence-frame { border: 1px solid CanvasText; }
.button-primary, .choice input:checked + span { forced-color-adjust: none; background: Highlight; color: HighlightText; }
button,
fieldset,
.choice,
.notice,
.workspace-grid,
.evidence-frame {
border: 1px solid CanvasText;
}
.button-primary,
.choice input:checked + span {
forced-color-adjust: none;
background: Highlight;
color: HighlightText;
}
}
@@ -1,21 +1,132 @@
const taxonomy = Object.freeze({
weapon: { views: ["detail", "blueprint", "crafting", "stat_range", "talent_grid", "augmentation"], records: { detail: ["weapon", "weapon_talent"], blueprint: ["weapon", "weapon_talent"], crafting: ["weapon"], stat_range: ["weapon"], talent_grid: ["weapon_talent"], augmentation: ["weapon_augmentation_profile"] } },
gear: { views: ["detail", "blueprint", "talent", "augmentation"], records: { detail: ["gear", "brand_set"], blueprint: ["gear", "brand_set"], talent: ["gear_talent"], augmentation: ["gear_augmentation_profile"] } },
skill_chip: { views: ["detail", "set_bonus", "talent"], records: { detail: ["skill_chip_set", "skill_chip_roll_pool"], set_bonus: ["skill_chip_set_bonus"], talent: ["skill_chip_talent"] } },
os_protocol: { views: ["detail", "secondary_profile", "talent"], records: { detail: ["os_protocol"], secondary_profile: ["os_protocol_secondary_profile"], talent: ["os_protocol_talent"] } },
specialization: { views: ["overview", "details", "ability", "focus_details", "focus_tree", "rank_details"], records: { overview: ["specialization"], details: ["specialization"], ability: ["specialization_ability"], focus_details: ["specialization_focus"], focus_tree: ["specialization_focus", "specialization_tree_node"], rank_details: ["specialization_tree_rank"] } },
weapon_mod: { views: ["detail"], records: { detail: ["weapon_mod"] } },
gear_mod: { views: ["detail"], records: { detail: ["gear_mod"] } },
unknown: { views: ["unknown"], records: { unknown: [] } }
weapon: {
views: [
'detail',
'blueprint',
'crafting',
'stat_range',
'talent_grid',
'augmentation',
],
records: {
detail: ['weapon', 'weapon_talent'],
blueprint: ['weapon', 'weapon_talent'],
crafting: ['weapon'],
stat_range: ['weapon'],
talent_grid: ['weapon_talent'],
augmentation: ['weapon_augmentation_profile'],
},
},
gear: {
views: ['detail', 'blueprint', 'talent', 'augmentation'],
records: {
detail: ['gear', 'brand_set'],
blueprint: ['gear', 'brand_set'],
talent: ['gear_talent'],
augmentation: ['gear_augmentation_profile'],
},
},
skill_chip: {
views: ['detail', 'set_bonus', 'talent'],
records: {
detail: ['skill_chip_set', 'skill_chip_roll_pool'],
set_bonus: ['skill_chip_set_bonus'],
talent: ['skill_chip_talent'],
},
},
os_protocol: {
views: ['detail', 'secondary_profile', 'talent'],
records: {
detail: ['os_protocol'],
secondary_profile: ['os_protocol_secondary_profile'],
talent: ['os_protocol_talent'],
},
},
specialization: {
views: [
'overview',
'details',
'ability',
'focus_details',
'focus_tree',
'rank_details',
],
records: {
overview: ['specialization'],
details: ['specialization'],
ability: ['specialization_ability'],
focus_details: ['specialization_focus'],
focus_tree: ['specialization_focus', 'specialization_tree_node'],
rank_details: ['specialization_tree_rank'],
},
},
weapon_mod: { views: ['detail'], records: { detail: ['weapon_mod'] } },
gear_mod: { views: ['detail'], records: { detail: ['gear_mod'] } },
unknown: { views: ['unknown'], records: { unknown: [] } },
});
const families = Object.keys(taxonomy);
const gearSlots = ["Mask", "Gloves", "Backpack", "Body Armor", "Holster", "Knee Pads"];
const abstentionReasons = ["low_confidence", "ambiguous_family", "ambiguous_view", "unsupported_family", "unsupported_view", "insufficient_visual_evidence", "occluded", "corrupt_image", "resource_limit", "cancelled"];
const gearSlots = [
'Mask',
'Gloves',
'Backpack',
'Body Armor',
'Holster',
'Knee Pads',
];
const abstentionReasons = [
'low_confidence',
'ambiguous_family',
'ambiguous_view',
'unsupported_family',
'unsupported_view',
'insufficient_visual_evidence',
'occluded',
'corrupt_image',
'resource_limit',
'cancelled',
];
const csrfPattern = /^[A-Za-z0-9_-]{32,128}$/;
const $ = (id) => document.getElementById(id);
const els = Object.freeze({
sessionState: $("session-state"), queueState: $("queue-state"), captureState: $("capture-state"), notice: $("notice"), noticeTitle: $("notice-title"), noticeCopy: $("notice-copy"), start: $("start-button"), stop: $("stop-button"), refresh: $("refresh-button"), filter: $("hide-reviewed"), list: $("capture-list"), listEmpty: $("capture-empty"), count: $("capture-count"), reviewTitle: $("review-title"), reviewState: $("review-state"), reviewEmpty: $("review-empty"), reviewContent: $("review-content"), media: $("capture-media"), meta: $("capture-meta"), labelForm: $("label-form"), validation: $("label-validation"), save: $("save-label-button"), reject: $("reject-button"), remove: $("delete-button"), family: $("family-options"), view: $("view-options"), gear: $("gear-slot-options"), records: $("record-type-options"), abstention: $("abstention-options"), viewFieldset: $("view-fieldset"), gearFieldset: $("gear-slot-fieldset"), recordFieldset: $("record-types-fieldset"), abstentionFieldset: $("abstention-fieldset"), dialog: $("confirmation-dialog"), dialogKicker: $("confirmation-kicker"), dialogTitle: $("confirmation-title"), dialogCopy: $("confirmation-copy"), dialogSubmit: $("confirmation-submit")
sessionState: $('session-state'),
queueState: $('queue-state'),
captureState: $('capture-state'),
notice: $('notice'),
noticeTitle: $('notice-title'),
noticeCopy: $('notice-copy'),
start: $('start-button'),
stop: $('stop-button'),
refresh: $('refresh-button'),
filter: $('hide-reviewed'),
list: $('capture-list'),
listEmpty: $('capture-empty'),
count: $('capture-count'),
reviewTitle: $('review-title'),
reviewState: $('review-state'),
reviewEmpty: $('review-empty'),
reviewContent: $('review-content'),
media: $('capture-media'),
meta: $('capture-meta'),
labelForm: $('label-form'),
validation: $('label-validation'),
save: $('save-label-button'),
reject: $('reject-button'),
remove: $('delete-button'),
family: $('family-options'),
view: $('view-options'),
gear: $('gear-slot-options'),
records: $('record-type-options'),
abstention: $('abstention-options'),
viewFieldset: $('view-fieldset'),
gearFieldset: $('gear-slot-fieldset'),
recordFieldset: $('record-types-fieldset'),
abstentionFieldset: $('abstention-fieldset'),
dialog: $('confirmation-dialog'),
dialogKicker: $('confirmation-kicker'),
dialogTitle: $('confirmation-title'),
dialogCopy: $('confirmation-copy'),
dialogSubmit: $('confirmation-submit'),
});
let csrfToken = null;
@@ -26,31 +137,75 @@ let pending = false;
let returnFocus = null;
let hideReviewed = false;
function setText(element, value) { element.textContent = value; }
function clearNode(node) { while (node.firstChild) node.removeChild(node.firstChild); }
function label(value) { return value.replaceAll("_", " "); }
function notice(kind, title, copy) { els.notice.dataset.kind = kind; setText(els.noticeTitle, title); setText(els.noticeCopy, copy); }
function hasValidCsrf(value) { return typeof value === "string" && csrfPattern.test(value); }
function selected(name) { return document.querySelector(`input[name="${name}"]:checked`)?.value || null; }
function selectedAll(name) { return [...document.querySelectorAll(`input[name="${name}"]:checked`)].map((input) => input.value); }
function currentFamily() { return selected("family"); }
function currentView() { return selected("view"); }
function isReviewedCapture(capture) { return capture?.classification !== null && capture?.classification !== undefined; }
function visibleCaptures() { return hideReviewed ? captures.filter((capture) => !isReviewedCapture(capture)) : captures; }
function abbreviateCaptureId(value) { return value.length <= 22 ? value : `${value.slice(0, 11)}…${value.slice(-8)}`; }
function setText(element, value) {
element.textContent = value;
}
function clearNode(node) {
while (node.firstChild) node.removeChild(node.firstChild);
}
function label(value) {
return value.replaceAll('_', ' ');
}
function notice(kind, title, copy) {
els.notice.dataset.kind = kind;
setText(els.noticeTitle, title);
setText(els.noticeCopy, copy);
}
function hasValidCsrf(value) {
return typeof value === 'string' && csrfPattern.test(value);
}
function selected(name) {
return document.querySelector(`input[name="${name}"]:checked`)?.value || null;
}
function selectedAll(name) {
return [...document.querySelectorAll(`input[name="${name}"]:checked`)].map(
(input) => input.value,
);
}
function currentFamily() {
return selected('family');
}
function currentView() {
return selected('view');
}
function isReviewedCapture(capture) {
return (
capture?.classification !== null && capture?.classification !== undefined
);
}
function visibleCaptures() {
return hideReviewed
? captures.filter((capture) => !isReviewedCapture(capture))
: captures;
}
function abbreviateCaptureId(value) {
return value.length <= 22
? value
: `${value.slice(0, 11)}…${value.slice(-8)}`;
}
function formatCaptureTime(value) {
if (typeof value !== "string" || value.length > 64) return "TIME UNAVAILABLE";
if (typeof value !== 'string' || value.length > 64) return 'TIME UNAVAILABLE';
const timestamp = Date.parse(value);
if (!Number.isFinite(timestamp)) return "TIME UNAVAILABLE";
if (!Number.isFinite(timestamp)) return 'TIME UNAVAILABLE';
const date = new Date(timestamp);
const parts = [date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate(), date.getUTCHours(), date.getUTCMinutes()];
if (parts.some((part) => !Number.isInteger(part))) return "TIME UNAVAILABLE";
const parts = [
date.getUTCFullYear(),
date.getUTCMonth() + 1,
date.getUTCDate(),
date.getUTCHours(),
date.getUTCMinutes(),
];
if (parts.some((part) => !Number.isInteger(part))) return 'TIME UNAVAILABLE';
const [year, month, day, hour, minute] = parts;
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")} ${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")} UTC`;
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')} ${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')} UTC`;
}
function activeCaptureProjection() {
if (!activeCapture) return null;
return captures.find((capture) => capture.capture_id === activeCapture.capture_id) || activeCapture;
return (
captures.find(
(capture) => capture.capture_id === activeCapture.capture_id,
) || activeCapture
);
}
function syncControls() {
@@ -62,23 +217,30 @@ function syncControls() {
els.save.disabled = pending || locked || !activeCapture || !isValidLabel();
els.reject.disabled = pending || locked || !activeCapture;
els.remove.disabled = pending || locked || !activeCapture;
document.querySelectorAll("#capture-list button").forEach((button) => {
document.querySelectorAll('#capture-list button').forEach((button) => {
button.disabled = pending || locked;
});
document.querySelectorAll("#label-form input").forEach((input) => {
const fieldset = input.closest("fieldset");
input.disabled = pending || locked || !activeCapture || Boolean(fieldset?.disabled || fieldset?.hidden);
document.querySelectorAll('#label-form input').forEach((input) => {
const fieldset = input.closest('fieldset');
input.disabled =
pending ||
locked ||
!activeCapture ||
Boolean(fieldset?.disabled || fieldset?.hidden);
});
els.labelForm.setAttribute("aria-busy", String(pending));
els.labelForm.setAttribute('aria-busy', String(pending));
}
function setBusy(value) {
pending = value;
syncControls();
}
function setBusy(value) { pending = value; syncControls(); }
function clearActiveDetail() {
activeCapture = null;
els.media.removeAttribute("src");
els.media.removeAttribute('src');
els.reviewContent.hidden = true;
els.reviewEmpty.hidden = false;
els.reviewTitle.textContent = "No capture selected";
els.reviewState.textContent = csrfToken ? "READY" : "LOCKED";
els.reviewTitle.textContent = 'No capture selected';
els.reviewState.textContent = csrfToken ? 'READY' : 'LOCKED';
renderLabelForm(null);
}
function lock(message) {
@@ -86,56 +248,125 @@ function lock(message) {
armed = null;
captures = [];
clearActiveDetail();
els.sessionState.textContent = "LOCKED";
els.captureState.textContent = "DISARMED";
els.queueState.textContent = "Queue unavailable";
els.sessionState.textContent = 'LOCKED';
els.captureState.textContent = 'DISARMED';
els.queueState.textContent = 'Queue unavailable';
els.listEmpty.hidden = false;
setText(els.listEmpty, message);
clearNode(els.list);
els.count.textContent = "0 shown / 0 loaded";
els.count.textContent = '0 shown / 0 loaded';
setBusy(false);
}
function validSession(payload) {
return Boolean(payload) && hasValidCsrf(payload.csrf_token) && typeof payload.armed === "boolean" && Number.isInteger(payload.queue_depth) && Number.isInteger(payload.queue_capacity) && payload.queue_depth >= 0 && payload.queue_depth <= payload.queue_capacity && payload.queue_capacity <= 256;
return (
Boolean(payload) &&
hasValidCsrf(payload.csrf_token) &&
typeof payload.armed === 'boolean' &&
Number.isInteger(payload.queue_depth) &&
Number.isInteger(payload.queue_capacity) &&
payload.queue_depth >= 0 &&
payload.queue_depth <= payload.queue_capacity &&
payload.queue_capacity <= 256
);
}
function updateSession(payload) {
armed = payload.armed;
els.sessionState.textContent = "UNLOCKED";
els.captureState.textContent = armed ? "ARMED" : "DISARMED";
els.sessionState.textContent = 'UNLOCKED';
els.captureState.textContent = armed ? 'ARMED' : 'DISARMED';
els.queueState.textContent = `Queue ${payload.queue_depth} / ${payload.queue_capacity}`;
if (!activeCapture) els.reviewState.textContent = "READY";
if (!activeCapture) els.reviewState.textContent = 'READY';
syncControls();
}
function safeJson(response) { return response.json().catch(() => null); }
function safeJson(response) {
return response.json().catch(() => null);
}
async function request(path, options = {}, mutation = false) {
const headers = new Headers(options.headers || {});
if (mutation) { headers.set("Content-Type", "application/json"); headers.set("X-CSRF-Token", csrfToken || ""); }
if (mutation) {
headers.set('Content-Type', 'application/json');
headers.set('X-CSRF-Token', csrfToken || '');
}
let response;
try { response = await fetch(path, { ...options, headers, credentials: "same-origin" }); } catch { notice("error", "NETWORK ERROR", "The local service did not respond. Your current review remains unchanged."); return null; }
if (response.status === 401 || response.status === 403) { lock("Local session expired or restarted. Open the provided local address again."); notice("error", "SESSION LOCKED", "The local session is no longer available. No action was retried."); return null; }
try {
response = await fetch(path, {
...options,
headers,
credentials: 'same-origin',
});
} catch {
notice(
'error',
'NETWORK ERROR',
'The local service did not respond. Your current review remains unchanged.',
);
return null;
}
if (response.status === 401 || response.status === 403) {
lock(
'Local session expired or restarted. Open the provided local address again.',
);
notice(
'error',
'SESSION LOCKED',
'The local session is no longer available. No action was retried.',
);
return null;
}
const payload = await safeJson(response);
if (payload === null) { notice("error", "RESPONSE ERROR", "The local service returned an unusable response. Your current review remains unchanged."); return null; }
if (payload === null) {
notice(
'error',
'RESPONSE ERROR',
'The local service returned an unusable response. Your current review remains unchanged.',
);
return null;
}
if (!response.ok) {
const retained = response.status === 409 && payload.error === "retained";
notice(retained ? "retained" : response.status === 409 ? "conflict" : "error", retained ? "RETAINED" : response.status === 409 ? "CONFLICT" : "ACTION FAILED", retained ? "Retention policy kept this evidence. No deletion occurred." : "The local service could not complete that action.");
const retained = response.status === 409 && payload.error === 'retained';
notice(
retained ? 'retained' : response.status === 409 ? 'conflict' : 'error',
retained
? 'RETAINED'
: response.status === 409
? 'CONFLICT'
: 'ACTION FAILED',
retained
? 'Retention policy kept this evidence. No deletion occurred.'
: 'The local service could not complete that action.',
);
return null;
}
return payload;
}
function acceptMutationCsrf(payload) {
if (hasValidCsrf(payload?.csrf_token)) { csrfToken = payload.csrf_token; return true; }
lock("The local service returned an invalid session update. Open the provided local address again.");
notice("error", "SESSION LOCKED", "The confirmed action was not retried.");
if (hasValidCsrf(payload?.csrf_token)) {
csrfToken = payload.csrf_token;
return true;
}
lock(
'The local service returned an invalid session update. Open the provided local address again.',
);
notice('error', 'SESSION LOCKED', 'The confirmed action was not retried.');
return false;
}
function addChoice(container, type, name, value, checked, onChange) {
const wrapper = document.createElement("label"); wrapper.className = "choice";
const input = document.createElement("input"); input.type = type; input.name = name; input.value = value; input.checked = checked; input.addEventListener("change", onChange);
const text = document.createElement("span"); text.textContent = label(value);
wrapper.append(input, text); container.append(wrapper);
const wrapper = document.createElement('label');
wrapper.className = 'choice';
const input = document.createElement('input');
input.type = type;
input.name = name;
input.value = value;
input.checked = checked;
input.addEventListener('change', onChange);
const text = document.createElement('span');
text.textContent = label(value);
wrapper.append(input, text);
container.append(wrapper);
}
function setChecked(name, value) {
document.querySelectorAll(`input[name="${name}"]`).forEach((input) => { input.checked = input.value === value; });
document.querySelectorAll(`input[name="${name}"]`).forEach((input) => {
input.checked = input.value === value;
});
}
function setCheckedMembers(name, values) {
const selectedValues = new Set(values);
@@ -145,83 +376,177 @@ function setCheckedMembers(name, values) {
}
function renderFamilies() {
clearNode(els.family);
families.forEach((family) => addChoice(els.family, "radio", "family", family, false, () => { renderViews(); renderDependentFields(); }));
families.forEach((family) =>
addChoice(els.family, 'radio', 'family', family, false, () => {
renderViews();
renderDependentFields();
}),
);
}
function renderViews() {
const family = currentFamily();
clearNode(els.view);
els.viewFieldset.disabled = !family;
if (!family) return;
taxonomy[family].views.forEach((view) => addChoice(els.view, "radio", "view", view, false, renderDependentFields));
taxonomy[family].views.forEach((view) =>
addChoice(els.view, 'radio', 'view', view, false, renderDependentFields),
);
}
function renderDependentFields() {
const family = currentFamily(); const view = currentView(); const unknown = family === "unknown"; const isGear = family === "gear";
els.gearFieldset.hidden = !isGear; els.gearFieldset.disabled = !isGear;
els.abstentionFieldset.hidden = !unknown; els.abstentionFieldset.disabled = !unknown;
const family = currentFamily();
const view = currentView();
const unknown = family === 'unknown';
const isGear = family === 'gear';
els.gearFieldset.hidden = !isGear;
els.gearFieldset.disabled = !isGear;
els.abstentionFieldset.hidden = !unknown;
els.abstentionFieldset.disabled = !unknown;
els.recordFieldset.disabled = !family || !view || unknown;
clearNode(els.gear); clearNode(els.records); clearNode(els.abstention);
if (isGear) gearSlots.forEach((slot) => addChoice(els.gear, "radio", "gear_slot", slot, false, validateLabel));
if (unknown) abstentionReasons.forEach((reason) => addChoice(els.abstention, "radio", "abstention_reason", reason, false, validateLabel));
if (!unknown && family && view) (taxonomy[family].records[view] || []).forEach((record) => addChoice(els.records, "checkbox", "record_type", record, false, validateLabel));
clearNode(els.gear);
clearNode(els.records);
clearNode(els.abstention);
if (isGear)
gearSlots.forEach((slot) =>
addChoice(els.gear, 'radio', 'gear_slot', slot, false, validateLabel),
);
if (unknown)
abstentionReasons.forEach((reason) =>
addChoice(
els.abstention,
'radio',
'abstention_reason',
reason,
false,
validateLabel,
),
);
if (!unknown && family && view)
(taxonomy[family].records[view] || []).forEach((record) =>
addChoice(
els.records,
'checkbox',
'record_type',
record,
false,
validateLabel,
),
);
validateLabel();
}
function labelPayload() {
const family = currentFamily(); const view = currentView(); const unknown = family === "unknown";
return { family, view, gear_slot: family === "gear" ? selected("gear_slot") : null, record_types: unknown ? [] : selectedAll("record_type"), confidence: 1.0, abstention_reason: unknown ? selected("abstention_reason") : null };
const family = currentFamily();
const view = currentView();
const unknown = family === 'unknown';
return {
family,
view,
gear_slot: family === 'gear' ? selected('gear_slot') : null,
record_types: unknown ? [] : selectedAll('record_type'),
confidence: 1.0,
abstention_reason: unknown ? selected('abstention_reason') : null,
};
}
function isValidLabel() {
const payload = labelPayload();
if (!payload.family || !payload.view) return false;
if (payload.family === "unknown") return payload.view === "unknown" && !payload.gear_slot && payload.record_types.length === 0 && Boolean(payload.abstention_reason);
return payload.record_types.length > 0 && (payload.family !== "gear" || Boolean(payload.gear_slot));
if (payload.family === 'unknown')
return (
payload.view === 'unknown' &&
!payload.gear_slot &&
payload.record_types.length === 0 &&
Boolean(payload.abstention_reason)
);
return (
payload.record_types.length > 0 &&
(payload.family !== 'gear' || Boolean(payload.gear_slot))
);
}
function validateLabel() {
const payload = labelPayload(); let message = "";
if (!payload.family) message = "Select the evidence family.";
else if (!payload.view) message = "Select the screen view.";
else if (payload.family === "unknown" && !payload.abstention_reason) message = "Select an explicit abstention reason for an unknown screen.";
else if (payload.family === "gear" && !payload.gear_slot) message = "Select the Gear slot shown by this evidence.";
else if (payload.family !== "unknown" && payload.record_types.length === 0) message = "Select at least one compatible canonical record type.";
const payload = labelPayload();
let message = '';
if (!payload.family) message = 'Select the evidence family.';
else if (!payload.view) message = 'Select the screen view.';
else if (payload.family === 'unknown' && !payload.abstention_reason)
message = 'Select an explicit abstention reason for an unknown screen.';
else if (payload.family === 'gear' && !payload.gear_slot)
message = 'Select the Gear slot shown by this evidence.';
else if (payload.family !== 'unknown' && payload.record_types.length === 0)
message = 'Select at least one compatible canonical record type.';
setText(els.validation, message);
if (!pending) syncControls();
return !message;
}
function renderLabelForm(classification) {
renderFamilies();
if (!classification) { renderViews(); renderDependentFields(); return; }
setChecked("family", classification.family);
if (!classification) {
renderViews();
renderDependentFields();
return;
}
setChecked('family', classification.family);
renderViews();
setChecked("view", classification.view);
setChecked('view', classification.view);
renderDependentFields();
if (classification.gear_slot) setChecked("gear_slot", classification.gear_slot);
setCheckedMembers("record_type", classification.record_types || []);
if (classification.abstention_reason) setChecked("abstention_reason", classification.abstention_reason);
if (classification.gear_slot)
setChecked('gear_slot', classification.gear_slot);
setCheckedMembers('record_type', classification.record_types || []);
if (classification.abstention_reason)
setChecked('abstention_reason', classification.abstention_reason);
validateLabel();
}
function renderCaptures() {
const visible = visibleCaptures();
clearNode(els.list); els.count.textContent = `${visible.length} shown / ${captures.length} loaded`; els.listEmpty.hidden = visible.length > 0;
clearNode(els.list);
els.count.textContent = `${visible.length} shown / ${captures.length} loaded`;
els.listEmpty.hidden = visible.length > 0;
if (!visible.length) {
setText(els.listEmpty, captures.length ? "ALL LOADED CAPTURES HIDDEN: clear Hide reviewed captures to inspect loaded captures." : "NO LOADED CAPTURES: refresh to check the latest local queue.");
setText(
els.listEmpty,
captures.length
? 'ALL LOADED CAPTURES HIDDEN: clear Hide reviewed captures to inspect loaded captures.'
: 'NO LOADED CAPTURES: refresh to check the latest local queue.',
);
return;
}
visible.forEach((capture) => {
const item = document.createElement("li"); const button = document.createElement("button"); button.type = "button";
if (activeCapture?.capture_id === capture.capture_id) button.setAttribute("aria-current", "true");
const reviewState = isReviewedCapture(capture) ? "REVIEWED" : "UNREVIEWED";
const item = document.createElement('li');
const button = document.createElement('button');
button.type = 'button';
if (activeCapture?.capture_id === capture.capture_id)
button.setAttribute('aria-current', 'true');
const reviewState = isReviewedCapture(capture) ? 'REVIEWED' : 'UNREVIEWED';
const displayedTime = formatCaptureTime(capture.received_at);
button.setAttribute("aria-label", `Capture ${capture.capture_id}. ${reviewState}. ${displayedTime}.`);
const id = document.createElement("span"); id.className = "capture-id"; id.textContent = abbreviateCaptureId(capture.capture_id);
const meta = document.createElement("span"); meta.className = "capture-meta";
const review = document.createElement("span"); review.className = "capture-review"; review.textContent = reviewState;
const time = document.createElement("span"); time.className = "capture-time"; time.textContent = displayedTime;
button.setAttribute(
'aria-label',
`Capture ${capture.capture_id}. ${reviewState}. ${displayedTime}.`,
);
const id = document.createElement('span');
id.className = 'capture-id';
id.textContent = abbreviateCaptureId(capture.capture_id);
const meta = document.createElement('span');
meta.className = 'capture-meta';
const review = document.createElement('span');
review.className = 'capture-review';
review.textContent = reviewState;
const time = document.createElement('span');
time.className = 'capture-time';
time.textContent = displayedTime;
meta.append(review, time);
button.append(id, meta); button.addEventListener("click", () => selectCapture(capture.capture_id)); item.append(button); els.list.append(item);
button.append(id, meta);
button.addEventListener('click', () => selectCapture(capture.capture_id));
item.append(button);
els.list.append(item);
});
syncControls();
}
function focusIfAvailable(control) { if (control && !control.disabled) control.focus(); }
function focusVisibleCaptureOrFilter() { focusIfAvailable(els.list.querySelector("button:not(:disabled)") || els.filter); }
function focusIfAvailable(control) {
if (control && !control.disabled) control.focus();
}
function focusVisibleCaptureOrFilter() {
focusIfAvailable(
els.list.querySelector('button:not(:disabled)') || els.filter,
);
}
function applyReviewedFilter() {
hideReviewed = els.filter.checked;
const activeProjection = activeCaptureProjection();
@@ -232,103 +557,295 @@ function applyReviewedFilter() {
}
async function refreshCaptures({ announce = true, fallbackFocus = null } = {}) {
if (!csrfToken || pending) return false;
setBusy(true); if (announce) notice("info", "LOADING", "Refreshing the local capture queue.");
const payload = await request("/api/captures");
if (!payload || !Array.isArray(payload.captures)) { setBusy(false); if (announce) focusIfAvailable(fallbackFocus); return false; }
setBusy(true);
if (announce)
notice('info', 'LOADING', 'Refreshing the local capture queue.');
const payload = await request('/api/captures');
if (!payload || !Array.isArray(payload.captures)) {
setBusy(false);
if (announce) focusIfAvailable(fallbackFocus);
return false;
}
captures = payload.captures;
const activeProjection = activeCaptureProjection();
if (activeCapture && (!activeProjection || (hideReviewed && isReviewedCapture(activeProjection)))) clearActiveDetail();
renderCaptures(); if (announce) notice("success", "QUEUE REFRESHED", "Local capture list updated."); setBusy(false); if (announce) focusIfAvailable(fallbackFocus); return true;
if (
activeCapture &&
(!activeProjection || (hideReviewed && isReviewedCapture(activeProjection)))
)
clearActiveDetail();
renderCaptures();
if (announce)
notice('success', 'QUEUE REFRESHED', 'Local capture list updated.');
setBusy(false);
if (announce) focusIfAvailable(fallbackFocus);
return true;
}
async function selectCapture(captureId, { announce = true } = {}) {
if (!csrfToken || pending) return;
setBusy(true); if (announce) notice("info", "LOADING", "Loading selected private evidence.");
const payload = await request(`/api/captures/${encodeURIComponent(captureId)}`);
if (!payload?.capture) { setBusy(false); return; }
activeCapture = payload.capture; els.reviewContent.hidden = false; els.reviewEmpty.hidden = true; els.reviewTitle.textContent = activeCapture.capture_id; els.reviewState.textContent = activeCapture.state;
setBusy(true);
if (announce) notice('info', 'LOADING', 'Loading selected private evidence.');
const payload = await request(
`/api/captures/${encodeURIComponent(captureId)}`,
);
if (!payload?.capture) {
setBusy(false);
return;
}
activeCapture = payload.capture;
els.reviewContent.hidden = false;
els.reviewEmpty.hidden = true;
els.reviewTitle.textContent = activeCapture.capture_id;
els.reviewState.textContent = activeCapture.state;
els.meta.textContent = `State: ${activeCapture.state} · Received: ${activeCapture.received_at}`;
els.media.removeAttribute("src"); els.media.src = `/api/captures/${encodeURIComponent(activeCapture.capture_id)}/media`;
renderCaptures(); renderLabelForm(activeCapture.classification); if (announce) notice("success", "CAPTURE READY", "Review the evidence, then save a manual label or choose a separate evidence action."); setBusy(false);
els.media.removeAttribute('src');
els.media.src = `/api/captures/${encodeURIComponent(activeCapture.capture_id)}/media`;
renderCaptures();
renderLabelForm(activeCapture.classification);
if (announce)
notice(
'success',
'CAPTURE READY',
'Review the evidence, then save a manual label or choose a separate evidence action.',
);
setBusy(false);
}
function routeForAction(action) {
if (action === "start") return ["/api/session/start", "START"];
if (action === "stop") return ["/api/session/stop", "STOP"];
if ((action === "reject" || action === "delete") && activeCapture) return [`/api/captures/${encodeURIComponent(activeCapture.capture_id)}/${action}`, action === "reject" ? "REJECT" : "DELETE"];
if (action === 'start') return ['/api/session/start', 'START'];
if (action === 'stop') return ['/api/session/stop', 'STOP'];
if ((action === 'reject' || action === 'delete') && activeCapture)
return [
`/api/captures/${encodeURIComponent(activeCapture.capture_id)}/${action}`,
action === 'reject' ? 'REJECT' : 'DELETE',
];
return null;
}
function showConfirmation(action, source) {
if ((action === "reject" || action === "delete") && !activeCapture) { notice("error", "ACTION UNAVAILABLE", "Select a capture before confirming this evidence action."); return; }
const messages = { start: ["START SESSION", "Start capture now? This is the only action that can arm capture.", "Start Session"], stop: ["STOP SESSION", "Stop capture now? New screenshots will not enter the queue after the session stops.", "Stop Session"], reject: ["REJECT EVIDENCE", "Reject this evidence? The evidence remains retained for review and will not be deleted.", "Reject evidence"], delete: ["DELETE CAPTURE", "Request deletion of this capture? Retention policy may refuse deletion. Rejection does not delete evidence.", "Delete capture"] };
const message = messages[action]; if (!message) return;
returnFocus = source; els.dialog.returnValue = "cancel"; els.dialog.dataset.action = action;
els.dialogKicker.textContent = "EXPLICIT CONFIRMATION"; els.dialogTitle.textContent = message[0]; els.dialogCopy.textContent = message[1]; els.dialogSubmit.textContent = message[2]; els.dialogSubmit.className = action === "delete" || action === "reject" ? "button button-danger" : "button button-primary";
els.dialog.showModal(); els.dialogSubmit.focus();
if ((action === 'reject' || action === 'delete') && !activeCapture) {
notice(
'error',
'ACTION UNAVAILABLE',
'Select a capture before confirming this evidence action.',
);
return;
}
const messages = {
start: [
'START SESSION',
'Start capture now? This is the only action that can arm capture.',
'Start Session',
],
stop: [
'STOP SESSION',
'Stop capture now? New screenshots will not enter the queue after the session stops.',
'Stop Session',
],
reject: [
'REJECT EVIDENCE',
'Reject this evidence? The evidence remains retained for review and will not be deleted.',
'Reject evidence',
],
delete: [
'DELETE CAPTURE',
'Request deletion of this capture? Retention policy may refuse deletion. Rejection does not delete evidence.',
'Delete capture',
],
};
const message = messages[action];
if (!message) return;
returnFocus = source;
els.dialog.returnValue = 'cancel';
els.dialog.dataset.action = action;
els.dialogKicker.textContent = 'EXPLICIT CONFIRMATION';
els.dialogTitle.textContent = message[0];
els.dialogCopy.textContent = message[1];
els.dialogSubmit.textContent = message[2];
els.dialogSubmit.className =
action === 'delete' || action === 'reject'
? 'button button-danger'
: 'button button-primary';
els.dialog.showModal();
els.dialogSubmit.focus();
}
async function confirmed(action, fallbackFocus) {
if (pending) return;
const route = routeForAction(action);
if (!route) { notice("error", "ACTION UNAVAILABLE", "Select a capture before confirming this evidence action."); fallbackFocus?.focus(); return; }
setBusy(true); notice("info", "PENDING", "The local service is processing your confirmed action.");
const [path, confirmation] = route; const payload = await request(path, { method: "POST", body: JSON.stringify({ confirmation }) }, true);
if (!payload || !acceptMutationCsrf(payload)) { setBusy(false); fallbackFocus?.focus(); return; }
if ((action === "start" || action === "stop") && !validSession(payload)) { lock("The local service returned an invalid session update. Open the provided local address again."); setBusy(false); return; }
if (action === "delete") clearActiveDetail();
if (action === "start" || action === "stop") updateSession(payload);
const successFocus = action === "start" ? els.stop : action === "stop" ? els.start : action === "delete" ? els.refresh : fallbackFocus;
setBusy(false); if (!(await refreshCaptures({ announce: false }))) { focusIfAvailable(successFocus); return; }
notice("success", "SUCCESS", action === "reject" ? "Evidence rejected and retained." : action === "delete" ? "Deletion request completed." : `Capture session ${action === "start" ? "armed" : "disarmed"}.`);
if (!route) {
notice(
'error',
'ACTION UNAVAILABLE',
'Select a capture before confirming this evidence action.',
);
fallbackFocus?.focus();
return;
}
setBusy(true);
notice(
'info',
'PENDING',
'The local service is processing your confirmed action.',
);
const [path, confirmation] = route;
const payload = await request(
path,
{ method: 'POST', body: JSON.stringify({ confirmation }) },
true,
);
if (!payload || !acceptMutationCsrf(payload)) {
setBusy(false);
fallbackFocus?.focus();
return;
}
if ((action === 'start' || action === 'stop') && !validSession(payload)) {
lock(
'The local service returned an invalid session update. Open the provided local address again.',
);
setBusy(false);
return;
}
if (action === 'delete') clearActiveDetail();
if (action === 'start' || action === 'stop') updateSession(payload);
const successFocus =
action === 'start'
? els.stop
: action === 'stop'
? els.start
: action === 'delete'
? els.refresh
: fallbackFocus;
setBusy(false);
if (!(await refreshCaptures({ announce: false }))) {
focusIfAvailable(successFocus);
return;
}
notice(
'success',
'SUCCESS',
action === 'reject'
? 'Evidence rejected and retained.'
: action === 'delete'
? 'Deletion request completed.'
: `Capture session ${action === 'start' ? 'armed' : 'disarmed'}.`,
);
focusIfAvailable(successFocus);
}
async function initialize() {
els.filter.checked = false; hideReviewed = false;
els.filter.checked = false;
hideReviewed = false;
renderLabelForm(null);
let fragment = window.location.hash; let token = null;
if (fragment.length > 1 && fragment.length <= 256) { const params = new URLSearchParams(fragment.slice(1)); const candidate = params.get("bootstrap"); if (candidate && csrfPattern.test(candidate)) token = candidate; }
if (fragment) history.replaceState(null, "", `${window.location.pathname}${window.location.search}`);
fragment = "";
let fragment = window.location.hash;
let token = null;
if (fragment.length > 1 && fragment.length <= 256) {
const params = new URLSearchParams(fragment.slice(1));
const candidate = params.get('bootstrap');
if (candidate && csrfPattern.test(candidate)) token = candidate;
}
if (fragment)
history.replaceState(
null,
'',
`${window.location.pathname}${window.location.search}`,
);
fragment = '';
if (token) {
const bootstrap = await request("/api/bootstrap", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token }) }); token = null;
if (!hasValidCsrf(bootstrap?.csrf_token)) { lock("The local service could not establish a safe session. Open the provided local address again."); return; }
const bootstrap = await request('/api/bootstrap', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
token = null;
if (!hasValidCsrf(bootstrap?.csrf_token)) {
lock(
'The local service could not establish a safe session. Open the provided local address again.',
);
return;
}
csrfToken = bootstrap.csrf_token;
}
const session = await request("/api/session");
if (!validSession(session)) { lock("LOCKED: start the local service with its provided bootstrap address. This browser has no process-local session."); return; }
csrfToken = session.csrf_token; updateSession(session); if (!(await refreshCaptures({ announce: false }))) return;
const startupCopy = session.armed ? "Capture is ARMED. Stop Session requires explicit confirmation." : "Capture is DISARMED. Start Session requires explicit confirmation.";
notice("success", "LOCAL SESSION READY", startupCopy);
const session = await request('/api/session');
if (!validSession(session)) {
lock(
'LOCKED: start the local service with its provided bootstrap address. This browser has no process-local session.',
);
return;
}
csrfToken = session.csrf_token;
updateSession(session);
if (!(await refreshCaptures({ announce: false }))) return;
const startupCopy = session.armed
? 'Capture is ARMED. Stop Session requires explicit confirmation.'
: 'Capture is DISARMED. Start Session requires explicit confirmation.';
notice('success', 'LOCAL SESSION READY', startupCopy);
}
els.start.addEventListener("click", () => showConfirmation("start", els.start));
els.stop.addEventListener("click", () => showConfirmation("stop", els.stop));
els.refresh.addEventListener("click", () => refreshCaptures({ fallbackFocus: els.refresh }));
els.labelForm.addEventListener("submit", async (event) => {
els.start.addEventListener('click', () => showConfirmation('start', els.start));
els.stop.addEventListener('click', () => showConfirmation('stop', els.stop));
els.refresh.addEventListener('click', () =>
refreshCaptures({ fallbackFocus: els.refresh }),
);
els.labelForm.addEventListener('submit', async (event) => {
event.preventDefault();
if (!activeCapture || !validateLabel() || pending) return;
const captureId = activeCapture.capture_id;
setBusy(true); notice("info", "PENDING", "Saving the manual reviewed label.");
const payload = await request(`/api/captures/${encodeURIComponent(captureId)}/label`, { method: "POST", body: JSON.stringify(labelPayload()) }, true);
if (!payload || !acceptMutationCsrf(payload)) { setBusy(false); return; }
setBusy(true);
notice('info', 'PENDING', 'Saving the manual reviewed label.');
const payload = await request(
`/api/captures/${encodeURIComponent(captureId)}/label`,
{ method: 'POST', body: JSON.stringify(labelPayload()) },
true,
);
if (!payload || !acceptMutationCsrf(payload)) {
setBusy(false);
return;
}
setBusy(false);
if (!(await refreshCaptures({ announce: false }))) { focusIfAvailable(els.save); return; }
const savedCapture = captures.find((capture) => capture.capture_id === captureId);
const savedCaptureIsHidden = Boolean(savedCapture && hideReviewed && isReviewedCapture(savedCapture));
if (savedCapture && !savedCaptureIsHidden) await selectCapture(captureId, { announce: false });
else { clearActiveDetail(); renderCaptures(); }
notice("success", "LABEL SAVED", "Manual reviewed label saved with confidence 1.0.");
if (!(await refreshCaptures({ announce: false }))) {
focusIfAvailable(els.save);
return;
}
const savedCapture = captures.find(
(capture) => capture.capture_id === captureId,
);
const savedCaptureIsHidden = Boolean(
savedCapture && hideReviewed && isReviewedCapture(savedCapture),
);
if (savedCapture && !savedCaptureIsHidden)
await selectCapture(captureId, { announce: false });
else {
clearActiveDetail();
renderCaptures();
}
notice(
'success',
'LABEL SAVED',
'Manual reviewed label saved with confidence 1.0.',
);
if (savedCaptureIsHidden) focusVisibleCaptureOrFilter();
else focusIfAvailable(els.save);
});
els.filter.addEventListener("change", applyReviewedFilter);
els.reject.addEventListener("click", () => showConfirmation("reject", els.reject));
els.remove.addEventListener("click", () => showConfirmation("delete", els.remove));
els.dialog.addEventListener("close", () => {
const action = els.dialog.dataset.action; const shouldConfirm = els.dialog.returnValue === "confirm";
els.dialog.dataset.action = "";
els.filter.addEventListener('change', applyReviewedFilter);
els.reject.addEventListener('click', () =>
showConfirmation('reject', els.reject),
);
els.remove.addEventListener('click', () =>
showConfirmation('delete', els.remove),
);
els.dialog.addEventListener('close', () => {
const action = els.dialog.dataset.action;
const shouldConfirm = els.dialog.returnValue === 'confirm';
els.dialog.dataset.action = '';
const focusTarget = returnFocus;
if (focusTarget) focusTarget.focus();
returnFocus = null;
if (shouldConfirm && action) confirmed(action, focusTarget);
});
els.dialog.addEventListener("cancel", () => { els.dialog.returnValue = "cancel"; });
els.media.addEventListener("error", () => notice("error", "MEDIA UNAVAILABLE", "The private evidence image could not be loaded. Refresh captures before retrying."));
els.dialog.addEventListener('cancel', () => {
els.dialog.returnValue = 'cancel';
});
els.media.addEventListener('error', () =>
notice(
'error',
'MEDIA UNAVAILABLE',
'The private evidence image could not be loaded. Refresh captures before retrying.',
),
);
initialize();
@@ -33,29 +33,46 @@
<p id="capture-state">DISARMED</p>
</div>
<div class="control-actions">
<button class="button button-primary" id="start-button" type="button" disabled>Start Session</button>
<button class="button" id="stop-button" type="button" disabled>Stop Session</button>
<button class="button" id="refresh-button" type="button" disabled>Refresh captures</button>
<button
class="button button-primary"
id="start-button"
type="button"
disabled
>
Start Session
</button>
<button class="button" id="stop-button" type="button" disabled>
Stop Session
</button>
<button class="button" id="refresh-button" type="button" disabled>
Refresh captures
</button>
</div>
</section>
<section class="workspace-grid" aria-label="Capture review workspace">
<aside class="capture-panel" aria-labelledby="capture-list-title">
<div class="panel-heading">
<div class="panel-heading">
<div>
<p class="kicker">PRIVATE QUEUE</p>
<h2 id="capture-list-title">Captures</h2>
</div>
<span id="capture-count">0 shown / 0 loaded</span>
</div>
<label class="queue-filter" for="hide-reviewed">
<input id="hide-reviewed" type="checkbox" />
<span>Hide reviewed captures</span>
</label>
<div class="capture-scroll" id="capture-scroll">
<p class="empty-copy" id="capture-empty">Authenticate to inspect the local capture queue.</p>
<ol class="capture-list" id="capture-list" aria-label="Available captures"></ol>
</div>
<span id="capture-count">0 shown / 0 loaded</span>
</div>
<label class="queue-filter" for="hide-reviewed">
<input id="hide-reviewed" type="checkbox" />
<span>Hide reviewed captures</span>
</label>
<div class="capture-scroll" id="capture-scroll">
<p class="empty-copy" id="capture-empty">
Authenticate to inspect the local capture queue.
</p>
<ol
class="capture-list"
id="capture-list"
aria-label="Available captures"
></ol>
</div>
</aside>
<section class="review-panel" aria-labelledby="review-title">
@@ -68,7 +85,10 @@
</div>
<div class="review-empty" id="review-empty">
<p>Select a capture after the local session is unlocked. Media remains private to this browser session.</p>
<p>
Select a capture after the local session is unlocked. Media
remains private to this browser session.
</p>
</div>
<div class="review-content" id="review-content" hidden>
@@ -95,7 +115,9 @@
<fieldset id="record-types-fieldset" disabled>
<legend>Canonical records evidenced</legend>
<p class="field-help" id="record-types-help">Choose every record type shown by this screen.</p>
<p class="field-help" id="record-types-help">
Choose every record type shown by this screen.
</p>
<div class="choice-grid" id="record-type-options"></div>
</fieldset>
@@ -104,27 +126,73 @@
<div class="choice-grid" id="abstention-options"></div>
</fieldset>
<p class="validation-message" id="label-validation" role="status"></p>
<p
class="validation-message"
id="label-validation"
role="status"
></p>
<div class="form-actions">
<button class="button button-primary" id="save-label-button" type="submit" disabled>Save reviewed label</button>
<button class="button button-danger" id="reject-button" type="button" disabled>Reject evidence</button>
<button class="button button-danger quiet-danger" id="delete-button" type="button" disabled>Delete capture</button>
<button
class="button button-primary"
id="save-label-button"
type="submit"
disabled
>
Save reviewed label
</button>
<button
class="button button-danger"
id="reject-button"
type="button"
disabled
>
Reject evidence
</button>
<button
class="button button-danger quiet-danger"
id="delete-button"
type="button"
disabled
>
Delete capture
</button>
</div>
<p class="action-help">Rejecting retains evidence. Deletion requires separate confirmation and may be retained by policy.</p>
<p class="action-help">
Rejecting retains evidence. Deletion requires separate
confirmation and may be retained by policy.
</p>
</form>
</div>
</section>
</section>
</main>
<dialog class="confirmation-dialog" id="confirmation-dialog" aria-labelledby="confirmation-title">
<dialog
class="confirmation-dialog"
id="confirmation-dialog"
aria-labelledby="confirmation-title"
>
<form method="dialog" id="confirmation-form">
<p class="kicker" id="confirmation-kicker">CONFIRMATION REQUIRED</p>
<h2 id="confirmation-title">Confirm action</h2>
<p id="confirmation-copy"></p>
<div class="dialog-actions">
<button class="button" id="confirmation-cancel" value="cancel" type="submit">Cancel</button>
<button class="button button-primary" id="confirmation-submit" value="confirm" type="submit">Confirm</button>
<button
class="button"
id="confirmation-cancel"
value="cancel"
type="submit"
>
Cancel
</button>
<button
class="button button-primary"
id="confirmation-submit"
value="confirm"
type="submit"
>
Confirm
</button>
</div>
</form>
</dialog>