feat: add community publishing and news syndication
This commit is contained in:
@@ -62,10 +62,17 @@
|
||||
},
|
||||
"unknown": {
|
||||
"gear_slot": false,
|
||||
"views": {"unknown": []}
|
||||
"views": { "unknown": [] }
|
||||
}
|
||||
},
|
||||
"gear_slots": ["Mask", "Gloves", "Backpack", "Body Armor", "Holster", "Knee Pads"],
|
||||
"gear_slots": [
|
||||
"Mask",
|
||||
"Gloves",
|
||||
"Backpack",
|
||||
"Body Armor",
|
||||
"Holster",
|
||||
"Knee Pads"
|
||||
],
|
||||
"abstention_reasons": [
|
||||
"low_confidence",
|
||||
"ambiguous_family",
|
||||
@@ -109,9 +116,20 @@
|
||||
},
|
||||
"extraction": {
|
||||
"pending": ["local_processing", "cancelled"],
|
||||
"local_processing": ["candidate_ready", "fallback_pending", "abstained", "failed", "cancelled"],
|
||||
"local_processing": [
|
||||
"candidate_ready",
|
||||
"fallback_pending",
|
||||
"abstained",
|
||||
"failed",
|
||||
"cancelled"
|
||||
],
|
||||
"fallback_pending": ["fallback_processing", "cancelled"],
|
||||
"fallback_processing": ["candidate_ready", "abstained", "failed", "cancelled"],
|
||||
"fallback_processing": [
|
||||
"candidate_ready",
|
||||
"abstained",
|
||||
"failed",
|
||||
"cancelled"
|
||||
],
|
||||
"candidate_ready": [],
|
||||
"abstained": [],
|
||||
"failed": ["pending"],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,11 @@
|
||||
"schema_version": 1,
|
||||
"archive_id": "archive-001",
|
||||
"created_at": "2026-07-31T12:00:00Z",
|
||||
"generator": {"name": "game-data-capture", "version": "0.1.0", "policy_version": "1"},
|
||||
"generator": {
|
||||
"name": "game-data-capture",
|
||||
"version": "0.1.0",
|
||||
"policy_version": "1"
|
||||
},
|
||||
"artifacts": [
|
||||
{
|
||||
"artifact_id": "image-001",
|
||||
@@ -15,7 +19,13 @@
|
||||
}
|
||||
],
|
||||
"captures": [
|
||||
{"capture_id": "capture-001", "artifact_id": "image-001", "state": "queued", "received_at": "2026-07-31T11:59:55Z", "failure_reason": null}
|
||||
{
|
||||
"capture_id": "capture-001",
|
||||
"artifact_id": "image-001",
|
||||
"state": "queued",
|
||||
"received_at": "2026-07-31T11:59:55Z",
|
||||
"failure_reason": null
|
||||
}
|
||||
],
|
||||
"screens": [
|
||||
{
|
||||
@@ -36,7 +46,13 @@
|
||||
"extractions": [],
|
||||
"groups": [],
|
||||
"reviews": [
|
||||
{"review_id": "review-001", "candidate_id": "candidate-001", "state": "approved", "updated_at": "2026-07-31T12:00:00Z", "note": "Name verified against the image."}
|
||||
{
|
||||
"review_id": "review-001",
|
||||
"candidate_id": "candidate-001",
|
||||
"state": "approved",
|
||||
"updated_at": "2026-07-31T12:00:00Z",
|
||||
"note": "Name verified against the image."
|
||||
}
|
||||
],
|
||||
"exports": [],
|
||||
"errors": [],
|
||||
@@ -48,10 +64,20 @@
|
||||
"normalized_value": "LVOA-C",
|
||||
"missing_reason": null,
|
||||
"confidence": 0.99,
|
||||
"source_engine": {"kind": "local_ocr", "engine_id": "fixture-ocr", "engine_version": "1"},
|
||||
"source_engine": {
|
||||
"kind": "local_ocr",
|
||||
"engine_id": "fixture-ocr",
|
||||
"engine_version": "1"
|
||||
},
|
||||
"image_artifact_id": "image-001",
|
||||
"image_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"region": {"coordinate_space": "normalized", "x": 0.1, "y": 0.1, "width": 0.3, "height": 0.08},
|
||||
"region": {
|
||||
"coordinate_space": "normalized",
|
||||
"x": 0.1,
|
||||
"y": 0.1,
|
||||
"width": 0.3,
|
||||
"height": 0.08
|
||||
},
|
||||
"correction_history": []
|
||||
},
|
||||
{
|
||||
@@ -61,10 +87,20 @@
|
||||
"normalized_value": 20,
|
||||
"missing_reason": null,
|
||||
"confidence": 0.98,
|
||||
"source_engine": {"kind": "local_ocr", "engine_id": "fixture-ocr", "engine_version": "1"},
|
||||
"source_engine": {
|
||||
"kind": "local_ocr",
|
||||
"engine_id": "fixture-ocr",
|
||||
"engine_version": "1"
|
||||
},
|
||||
"image_artifact_id": "image-001",
|
||||
"image_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"region": {"coordinate_space": "pixels", "x": 50, "y": 50, "width": 80, "height": 30},
|
||||
"region": {
|
||||
"coordinate_space": "pixels",
|
||||
"x": 50,
|
||||
"y": 50,
|
||||
"width": 80,
|
||||
"height": 30
|
||||
},
|
||||
"correction_history": []
|
||||
},
|
||||
{
|
||||
@@ -74,10 +110,20 @@
|
||||
"normalized_value": "High-end",
|
||||
"missing_reason": null,
|
||||
"confidence": 0.96,
|
||||
"source_engine": {"kind": "local_ocr", "engine_id": "fixture-ocr", "engine_version": "1"},
|
||||
"source_engine": {
|
||||
"kind": "local_ocr",
|
||||
"engine_id": "fixture-ocr",
|
||||
"engine_version": "1"
|
||||
},
|
||||
"image_artifact_id": "image-001",
|
||||
"image_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"region": {"coordinate_space": "pixels", "x": 50, "y": 85, "width": 140, "height": 30},
|
||||
"region": {
|
||||
"coordinate_space": "pixels",
|
||||
"x": 50,
|
||||
"y": 85,
|
||||
"width": 140,
|
||||
"height": 30
|
||||
},
|
||||
"correction_history": []
|
||||
},
|
||||
{
|
||||
@@ -87,10 +133,20 @@
|
||||
"normalized_value": 1,
|
||||
"missing_reason": null,
|
||||
"confidence": 0.97,
|
||||
"source_engine": {"kind": "local_ocr", "engine_id": "fixture-ocr", "engine_version": "1"},
|
||||
"source_engine": {
|
||||
"kind": "local_ocr",
|
||||
"engine_id": "fixture-ocr",
|
||||
"engine_version": "1"
|
||||
},
|
||||
"image_artifact_id": "image-001",
|
||||
"image_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"region": {"coordinate_space": "pixels", "x": 200, "y": 85, "width": 60, "height": 30},
|
||||
"region": {
|
||||
"coordinate_space": "pixels",
|
||||
"x": 200,
|
||||
"y": 85,
|
||||
"width": 60,
|
||||
"height": 30
|
||||
},
|
||||
"correction_history": []
|
||||
},
|
||||
{
|
||||
@@ -100,10 +156,20 @@
|
||||
"normalized_value": null,
|
||||
"missing_reason": "not_visible",
|
||||
"confidence": 0,
|
||||
"source_engine": {"kind": "manual", "engine_id": "review", "engine_version": "1"},
|
||||
"source_engine": {
|
||||
"kind": "manual",
|
||||
"engine_id": "review",
|
||||
"engine_version": "1"
|
||||
},
|
||||
"image_artifact_id": "image-001",
|
||||
"image_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"region": {"coordinate_space": "normalized", "x": 0, "y": 0, "width": 1, "height": 1},
|
||||
"region": {
|
||||
"coordinate_space": "normalized",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1,
|
||||
"height": 1
|
||||
},
|
||||
"correction_history": []
|
||||
},
|
||||
{
|
||||
@@ -113,18 +179,36 @@
|
||||
"normalized_value": 12400,
|
||||
"missing_reason": null,
|
||||
"confidence": 1,
|
||||
"source_engine": {"kind": "manual", "engine_id": "review", "engine_version": "1"},
|
||||
"source_engine": {
|
||||
"kind": "manual",
|
||||
"engine_id": "review",
|
||||
"engine_version": "1"
|
||||
},
|
||||
"image_artifact_id": "image-001",
|
||||
"image_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"region": {"coordinate_space": "pixels", "x": 300, "y": 200, "width": 160, "height": 40},
|
||||
"region": {
|
||||
"coordinate_space": "pixels",
|
||||
"x": 300,
|
||||
"y": 200,
|
||||
"width": 160,
|
||||
"height": 40
|
||||
},
|
||||
"correction_history": [
|
||||
{
|
||||
"correction_id": "correction-001",
|
||||
"corrected_at": "2026-07-31T12:00:00Z",
|
||||
"actor": "local_user",
|
||||
"reason": "Corrected a missing zero.",
|
||||
"before": {"raw_text": "1,240", "normalized_value": 1240, "missing_reason": null},
|
||||
"after": {"raw_text": "12,400", "normalized_value": 12400, "missing_reason": null}
|
||||
"before": {
|
||||
"raw_text": "1,240",
|
||||
"normalized_value": 1240,
|
||||
"missing_reason": null
|
||||
},
|
||||
"after": {
|
||||
"raw_text": "12,400",
|
||||
"normalized_value": 12400,
|
||||
"missing_reason": null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -135,13 +219,43 @@
|
||||
"family": "weapon",
|
||||
"source_screen_ids": ["screen-001"],
|
||||
"observed_context": {
|
||||
"level": {"path": "observed.level", "value": 20, "missing_reason": null, "evidence_refs": ["evidence-level"], "uncertainty_note": null},
|
||||
"quality": {"path": "observed.quality", "value": "High-end", "missing_reason": null, "evidence_refs": ["evidence-quality"], "uncertainty_note": null},
|
||||
"tier": {"path": "observed.tier", "value": 1, "missing_reason": null, "evidence_refs": ["evidence-tier"], "uncertainty_note": null},
|
||||
"upgrade_state": {"path": "observed.upgrade_state", "value": null, "missing_reason": "not_visible", "evidence_refs": ["evidence-upgrade"], "uncertainty_note": "The screen does not show an upgrade indicator."}
|
||||
"level": {
|
||||
"path": "observed.level",
|
||||
"value": 20,
|
||||
"missing_reason": null,
|
||||
"evidence_refs": ["evidence-level"],
|
||||
"uncertainty_note": null
|
||||
},
|
||||
"quality": {
|
||||
"path": "observed.quality",
|
||||
"value": "High-end",
|
||||
"missing_reason": null,
|
||||
"evidence_refs": ["evidence-quality"],
|
||||
"uncertainty_note": null
|
||||
},
|
||||
"tier": {
|
||||
"path": "observed.tier",
|
||||
"value": 1,
|
||||
"missing_reason": null,
|
||||
"evidence_refs": ["evidence-tier"],
|
||||
"uncertainty_note": null
|
||||
},
|
||||
"upgrade_state": {
|
||||
"path": "observed.upgrade_state",
|
||||
"value": null,
|
||||
"missing_reason": "not_visible",
|
||||
"evidence_refs": ["evidence-upgrade"],
|
||||
"uncertainty_note": "The screen does not show an upgrade indicator."
|
||||
}
|
||||
},
|
||||
"fields": [
|
||||
{"path": "observed.damage", "value": 12400, "missing_reason": null, "evidence_refs": ["evidence-damage"], "uncertainty_note": "This is the configured level-20 instance value, not a canonical range endpoint."}
|
||||
{
|
||||
"path": "observed.damage",
|
||||
"value": 12400,
|
||||
"missing_reason": null,
|
||||
"evidence_refs": ["evidence-damage"],
|
||||
"uncertainty_note": "This is the configured level-20 instance value, not a canonical range endpoint."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -152,7 +266,13 @@
|
||||
"source_instance_ids": ["instance-001"],
|
||||
"evidence_refs": ["evidence-name"],
|
||||
"fields": [
|
||||
{"path": "weapon.name", "value": "LVOA-C", "missing_reason": null, "evidence_refs": ["evidence-name"], "uncertainty_note": null}
|
||||
{
|
||||
"path": "weapon.name",
|
||||
"value": "LVOA-C",
|
||||
"missing_reason": null,
|
||||
"evidence_refs": ["evidence-name"],
|
||||
"uncertainty_note": null
|
||||
}
|
||||
],
|
||||
"completeness": "incomplete",
|
||||
"relationship_status": "unresolved",
|
||||
|
||||
@@ -30,10 +30,20 @@
|
||||
"normalized_value": "LVOA-C",
|
||||
"missing_reason": null,
|
||||
"confidence": 0.99,
|
||||
"source_engine": {"kind": "local_ocr", "engine_id": "fixture-ocr", "engine_version": "1"},
|
||||
"source_engine": {
|
||||
"kind": "local_ocr",
|
||||
"engine_id": "fixture-ocr",
|
||||
"engine_version": "1"
|
||||
},
|
||||
"image_artifact_id": "image-001",
|
||||
"image_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"region": {"coordinate_space": "normalized", "x": 0.1, "y": 0.1, "width": 0.3, "height": 0.08},
|
||||
"region": {
|
||||
"coordinate_space": "normalized",
|
||||
"x": 0.1,
|
||||
"y": 0.1,
|
||||
"width": 0.3,
|
||||
"height": 0.08
|
||||
},
|
||||
"correction_history": []
|
||||
}
|
||||
],
|
||||
@@ -47,10 +57,24 @@
|
||||
"source_candidate_ids": ["candidate-001"],
|
||||
"observed_instance_ids": ["instance-001"],
|
||||
"fields": [
|
||||
{"path": "weapon.name", "value": "LVOA-C", "missing_reason": null, "evidence_refs": ["evidence-name"], "uncertainty_note": null}
|
||||
{
|
||||
"path": "weapon.name",
|
||||
"value": "LVOA-C",
|
||||
"missing_reason": null,
|
||||
"evidence_refs": ["evidence-name"],
|
||||
"uncertainty_note": null
|
||||
}
|
||||
],
|
||||
"relationships": {"status": "unresolved", "evidence_refs": [], "note": "ISAC must resolve and validate canonical relationships before submission."},
|
||||
"mechanics": {"status": "not_applicable", "evidence_refs": [], "note": null}
|
||||
"relationships": {
|
||||
"status": "unresolved",
|
||||
"evidence_refs": [],
|
||||
"note": "ISAC must resolve and validate canonical relationships before submission."
|
||||
},
|
||||
"mechanics": {
|
||||
"status": "not_applicable",
|
||||
"evidence_refs": [],
|
||||
"note": null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,191 +5,407 @@
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version", "handoff_id", "created_at", "source_archive", "trust_boundary",
|
||||
"source_artifacts", "evidence_values", "records"
|
||||
"schema_version",
|
||||
"handoff_id",
|
||||
"created_at",
|
||||
"source_archive",
|
||||
"trust_boundary",
|
||||
"source_artifacts",
|
||||
"evidence_values",
|
||||
"records"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {"const": 1},
|
||||
"handoff_id": {"$ref": "#/$defs/id"},
|
||||
"created_at": {"$ref": "#/$defs/timestamp"},
|
||||
"schema_version": { "const": 1 },
|
||||
"handoff_id": { "$ref": "#/$defs/id" },
|
||||
"created_at": { "$ref": "#/$defs/timestamp" },
|
||||
"source_archive": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["archive_id", "manifest_sha256", "schema_version"],
|
||||
"properties": {
|
||||
"archive_id": {"$ref": "#/$defs/id"}, "manifest_sha256": {"$ref": "#/$defs/sha256"},
|
||||
"schema_version": {"const": 1}
|
||||
"archive_id": { "$ref": "#/$defs/id" },
|
||||
"manifest_sha256": { "$ref": "#/$defs/sha256" },
|
||||
"schema_version": { "const": 1 }
|
||||
}
|
||||
},
|
||||
"trust_boundary": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["intended_destination", "mutation_authority", "requires_authenticated_action", "preserves_moderation", "creates_audit_events"],
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"intended_destination",
|
||||
"mutation_authority",
|
||||
"requires_authenticated_action",
|
||||
"preserves_moderation",
|
||||
"creates_audit_events"
|
||||
],
|
||||
"properties": {
|
||||
"intended_destination": {"const": "authenticated_isac_intake"},
|
||||
"mutation_authority": {"const": "none"},
|
||||
"requires_authenticated_action": {"const": true},
|
||||
"preserves_moderation": {"const": true},
|
||||
"creates_audit_events": {"const": false}
|
||||
"intended_destination": { "const": "authenticated_isac_intake" },
|
||||
"mutation_authority": { "const": "none" },
|
||||
"requires_authenticated_action": { "const": true },
|
||||
"preserves_moderation": { "const": true },
|
||||
"creates_audit_events": { "const": false }
|
||||
}
|
||||
},
|
||||
"source_artifacts": {"type": "array", "maxItems": 10000, "items": {"$ref": "#/$defs/sourceArtifact"}},
|
||||
"evidence_values": {"type": "array", "maxItems": 100000, "items": {"$ref": "#/$defs/evidenceValue"}},
|
||||
"records": {"type": "array", "maxItems": 20000, "items": {"$ref": "#/$defs/handoffRecord"}}
|
||||
"source_artifacts": {
|
||||
"type": "array",
|
||||
"maxItems": 10000,
|
||||
"items": { "$ref": "#/$defs/sourceArtifact" }
|
||||
},
|
||||
"evidence_values": {
|
||||
"type": "array",
|
||||
"maxItems": 100000,
|
||||
"items": { "$ref": "#/$defs/evidenceValue" }
|
||||
},
|
||||
"records": {
|
||||
"type": "array",
|
||||
"maxItems": 20000,
|
||||
"items": { "$ref": "#/$defs/handoffRecord" }
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
|
||||
"sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
|
||||
"timestamp": {"type": "string", "format": "date-time", "maxLength": 40},
|
||||
"recordType": {"enum": [
|
||||
"weapon", "weapon_talent", "weapon_augmentation_profile", "gear", "brand_set",
|
||||
"gear_talent", "gear_augmentation_profile", "skill_chip_set", "skill_chip_set_bonus",
|
||||
"skill_chip_roll_pool", "skill_chip_talent", "os_protocol", "os_protocol_secondary_profile",
|
||||
"os_protocol_talent", "specialization", "specialization_focus", "specialization_ability",
|
||||
"specialization_tree_node", "specialization_tree_rank", "weapon_mod", "gear_mod"
|
||||
]},
|
||||
"missingReason": {"enum": [
|
||||
"not_visible", "occluded", "unreadable", "not_present_on_screen", "unsupported",
|
||||
"ambiguous", "extraction_failed", "not_applicable", "redacted"
|
||||
]},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
|
||||
"timestamp": { "type": "string", "format": "date-time", "maxLength": 40 },
|
||||
"recordType": {
|
||||
"enum": [
|
||||
"weapon",
|
||||
"weapon_talent",
|
||||
"weapon_augmentation_profile",
|
||||
"gear",
|
||||
"brand_set",
|
||||
"gear_talent",
|
||||
"gear_augmentation_profile",
|
||||
"skill_chip_set",
|
||||
"skill_chip_set_bonus",
|
||||
"skill_chip_roll_pool",
|
||||
"skill_chip_talent",
|
||||
"os_protocol",
|
||||
"os_protocol_secondary_profile",
|
||||
"os_protocol_talent",
|
||||
"specialization",
|
||||
"specialization_focus",
|
||||
"specialization_ability",
|
||||
"specialization_tree_node",
|
||||
"specialization_tree_rank",
|
||||
"weapon_mod",
|
||||
"gear_mod"
|
||||
]
|
||||
},
|
||||
"missingReason": {
|
||||
"enum": [
|
||||
"not_visible",
|
||||
"occluded",
|
||||
"unreadable",
|
||||
"not_present_on_screen",
|
||||
"unsupported",
|
||||
"ambiguous",
|
||||
"extraction_failed",
|
||||
"not_applicable",
|
||||
"redacted"
|
||||
]
|
||||
},
|
||||
"jsonValue": {
|
||||
"oneOf": [
|
||||
{"type": "null"}, {"type": "boolean"}, {"type": "number"},
|
||||
{"type": "string", "maxLength": 8192},
|
||||
{"type": "array", "maxItems": 500, "items": {"$ref": "#/$defs/jsonValue"}},
|
||||
{"type": "object", "maxProperties": 500, "additionalProperties": {"$ref": "#/$defs/jsonValue"}}
|
||||
{ "type": "null" },
|
||||
{ "type": "boolean" },
|
||||
{ "type": "number" },
|
||||
{ "type": "string", "maxLength": 8192 },
|
||||
{
|
||||
"type": "array",
|
||||
"maxItems": 500,
|
||||
"items": { "$ref": "#/$defs/jsonValue" }
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"maxProperties": 500,
|
||||
"additionalProperties": { "$ref": "#/$defs/jsonValue" }
|
||||
}
|
||||
]
|
||||
},
|
||||
"sourceEngine": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["kind", "engine_id", "engine_version"],
|
||||
"properties": {
|
||||
"kind": {"enum": ["local_ocr", "openai_vision", "classifier", "manual", "video_decoder"]},
|
||||
"engine_id": {"type": "string", "minLength": 1, "maxLength": 100},
|
||||
"engine_version": {"type": "string", "minLength": 1, "maxLength": 100}
|
||||
"kind": {
|
||||
"enum": [
|
||||
"local_ocr",
|
||||
"openai_vision",
|
||||
"classifier",
|
||||
"manual",
|
||||
"video_decoder"
|
||||
]
|
||||
},
|
||||
"engine_id": { "type": "string", "minLength": 1, "maxLength": 100 },
|
||||
"engine_version": { "type": "string", "minLength": 1, "maxLength": 100 }
|
||||
}
|
||||
},
|
||||
"region": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object", "additionalProperties": false,
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["coordinate_space", "x", "y", "width", "height"],
|
||||
"properties": {
|
||||
"coordinate_space": {"const": "normalized"},
|
||||
"x": {"type": "number", "minimum": 0, "maximum": 1},
|
||||
"y": {"type": "number", "minimum": 0, "maximum": 1},
|
||||
"width": {"type": "number", "exclusiveMinimum": 0, "maximum": 1},
|
||||
"height": {"type": "number", "exclusiveMinimum": 0, "maximum": 1}
|
||||
"coordinate_space": { "const": "normalized" },
|
||||
"x": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"y": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"width": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 },
|
||||
"height": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object", "additionalProperties": false,
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["coordinate_space", "x", "y", "width", "height"],
|
||||
"properties": {
|
||||
"coordinate_space": {"const": "pixels"},
|
||||
"x": {"type": "integer", "minimum": 0, "maximum": 100000},
|
||||
"y": {"type": "integer", "minimum": 0, "maximum": 100000},
|
||||
"width": {"type": "integer", "minimum": 1, "maximum": 100000},
|
||||
"height": {"type": "integer", "minimum": 1, "maximum": 100000}
|
||||
"coordinate_space": { "const": "pixels" },
|
||||
"x": { "type": "integer", "minimum": 0, "maximum": 100000 },
|
||||
"y": { "type": "integer", "minimum": 0, "maximum": 100000 },
|
||||
"width": { "type": "integer", "minimum": 1, "maximum": 100000 },
|
||||
"height": { "type": "integer", "minimum": 1, "maximum": 100000 }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"valueState": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["raw_text", "normalized_value", "missing_reason"],
|
||||
"properties": {
|
||||
"raw_text": {"type": ["string", "null"], "maxLength": 8192},
|
||||
"normalized_value": {"$ref": "#/$defs/jsonValue"},
|
||||
"missing_reason": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/missingReason"}]}
|
||||
"raw_text": { "type": ["string", "null"], "maxLength": 8192 },
|
||||
"normalized_value": { "$ref": "#/$defs/jsonValue" },
|
||||
"missing_reason": {
|
||||
"oneOf": [{ "type": "null" }, { "$ref": "#/$defs/missingReason" }]
|
||||
}
|
||||
},
|
||||
"oneOf": [
|
||||
{"properties": {"normalized_value": {"type": "null"}, "missing_reason": {"$ref": "#/$defs/missingReason"}}},
|
||||
{"properties": {"normalized_value": {"not": {"type": "null"}}, "missing_reason": {"type": "null"}}}
|
||||
{
|
||||
"properties": {
|
||||
"normalized_value": { "type": "null" },
|
||||
"missing_reason": { "$ref": "#/$defs/missingReason" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"normalized_value": { "not": { "type": "null" } },
|
||||
"missing_reason": { "type": "null" }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"correction": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["correction_id", "corrected_at", "actor", "reason", "before", "after"],
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"correction_id",
|
||||
"corrected_at",
|
||||
"actor",
|
||||
"reason",
|
||||
"before",
|
||||
"after"
|
||||
],
|
||||
"properties": {
|
||||
"correction_id": {"$ref": "#/$defs/id"}, "corrected_at": {"$ref": "#/$defs/timestamp"},
|
||||
"actor": {"enum": ["local_user", "system_reprocess"]},
|
||||
"reason": {"type": "string", "minLength": 1, "maxLength": 1000},
|
||||
"before": {"$ref": "#/$defs/valueState"}, "after": {"$ref": "#/$defs/valueState"}
|
||||
"correction_id": { "$ref": "#/$defs/id" },
|
||||
"corrected_at": { "$ref": "#/$defs/timestamp" },
|
||||
"actor": { "enum": ["local_user", "system_reprocess"] },
|
||||
"reason": { "type": "string", "minLength": 1, "maxLength": 1000 },
|
||||
"before": { "$ref": "#/$defs/valueState" },
|
||||
"after": { "$ref": "#/$defs/valueState" }
|
||||
}
|
||||
},
|
||||
"evidenceValue": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["evidence_id", "field_path", "raw_text", "normalized_value", "missing_reason", "confidence", "source_engine", "image_artifact_id", "image_sha256", "region", "correction_history"],
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"evidence_id",
|
||||
"field_path",
|
||||
"raw_text",
|
||||
"normalized_value",
|
||||
"missing_reason",
|
||||
"confidence",
|
||||
"source_engine",
|
||||
"image_artifact_id",
|
||||
"image_sha256",
|
||||
"region",
|
||||
"correction_history"
|
||||
],
|
||||
"properties": {
|
||||
"evidence_id": {"$ref": "#/$defs/id"},
|
||||
"field_path": {"type": "string", "pattern": "^[A-Za-z0-9_.-]{1,256}$"},
|
||||
"raw_text": {"type": ["string", "null"], "maxLength": 8192},
|
||||
"normalized_value": {"$ref": "#/$defs/jsonValue"},
|
||||
"missing_reason": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/missingReason"}]},
|
||||
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
||||
"source_engine": {"$ref": "#/$defs/sourceEngine"},
|
||||
"image_artifact_id": {"$ref": "#/$defs/id"}, "image_sha256": {"$ref": "#/$defs/sha256"},
|
||||
"region": {"$ref": "#/$defs/region"},
|
||||
"correction_history": {"type": "array", "maxItems": 100, "items": {"$ref": "#/$defs/correction"}}
|
||||
"evidence_id": { "$ref": "#/$defs/id" },
|
||||
"field_path": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9_.-]{1,256}$"
|
||||
},
|
||||
"raw_text": { "type": ["string", "null"], "maxLength": 8192 },
|
||||
"normalized_value": { "$ref": "#/$defs/jsonValue" },
|
||||
"missing_reason": {
|
||||
"oneOf": [{ "type": "null" }, { "$ref": "#/$defs/missingReason" }]
|
||||
},
|
||||
"confidence": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"source_engine": { "$ref": "#/$defs/sourceEngine" },
|
||||
"image_artifact_id": { "$ref": "#/$defs/id" },
|
||||
"image_sha256": { "$ref": "#/$defs/sha256" },
|
||||
"region": { "$ref": "#/$defs/region" },
|
||||
"correction_history": {
|
||||
"type": "array",
|
||||
"maxItems": 100,
|
||||
"items": { "$ref": "#/$defs/correction" }
|
||||
}
|
||||
},
|
||||
"oneOf": [
|
||||
{"properties": {"normalized_value": {"type": "null"}, "missing_reason": {"$ref": "#/$defs/missingReason"}}},
|
||||
{"properties": {"normalized_value": {"not": {"type": "null"}}, "missing_reason": {"type": "null"}}}
|
||||
{
|
||||
"properties": {
|
||||
"normalized_value": { "type": "null" },
|
||||
"missing_reason": { "$ref": "#/$defs/missingReason" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"normalized_value": { "not": { "type": "null" } },
|
||||
"missing_reason": { "type": "null" }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"fieldClaim": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["path", "value", "missing_reason", "evidence_refs", "uncertainty_note"],
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"path",
|
||||
"value",
|
||||
"missing_reason",
|
||||
"evidence_refs",
|
||||
"uncertainty_note"
|
||||
],
|
||||
"properties": {
|
||||
"path": {"type": "string", "pattern": "^[A-Za-z0-9_.-]{1,256}$"},
|
||||
"value": {"$ref": "#/$defs/jsonValue"},
|
||||
"missing_reason": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/missingReason"}]},
|
||||
"evidence_refs": {"type": "array", "minItems": 1, "maxItems": 100, "uniqueItems": true, "items": {"$ref": "#/$defs/id"}},
|
||||
"uncertainty_note": {"type": ["string", "null"], "maxLength": 2000}
|
||||
"path": { "type": "string", "pattern": "^[A-Za-z0-9_.-]{1,256}$" },
|
||||
"value": { "$ref": "#/$defs/jsonValue" },
|
||||
"missing_reason": {
|
||||
"oneOf": [{ "type": "null" }, { "$ref": "#/$defs/missingReason" }]
|
||||
},
|
||||
"evidence_refs": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 100,
|
||||
"uniqueItems": true,
|
||||
"items": { "$ref": "#/$defs/id" }
|
||||
},
|
||||
"uncertainty_note": { "type": ["string", "null"], "maxLength": 2000 }
|
||||
},
|
||||
"oneOf": [
|
||||
{"properties": {"value": {"type": "null"}, "missing_reason": {"$ref": "#/$defs/missingReason"}}},
|
||||
{"properties": {"value": {"not": {"type": "null"}}, "missing_reason": {"type": "null"}}}
|
||||
{
|
||||
"properties": {
|
||||
"value": { "type": "null" },
|
||||
"missing_reason": { "$ref": "#/$defs/missingReason" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"value": { "not": { "type": "null" } },
|
||||
"missing_reason": { "type": "null" }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"sourceArtifact": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["artifact_id", "sha256", "media_type", "byte_size"],
|
||||
"properties": {
|
||||
"artifact_id": {"$ref": "#/$defs/id"}, "sha256": {"$ref": "#/$defs/sha256"},
|
||||
"media_type": {"enum": ["image/png", "image/webp", "image/avif"]},
|
||||
"byte_size": {"type": "integer", "minimum": 1, "maximum": 67108864}
|
||||
"artifact_id": { "$ref": "#/$defs/id" },
|
||||
"sha256": { "$ref": "#/$defs/sha256" },
|
||||
"media_type": { "enum": ["image/png", "image/webp", "image/avif"] },
|
||||
"byte_size": { "type": "integer", "minimum": 1, "maximum": 67108864 }
|
||||
}
|
||||
},
|
||||
"evidenceSection": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["status", "evidence_refs", "note"],
|
||||
"properties": {
|
||||
"status": {"enum": ["evidenced", "unresolved", "unsupported", "not_applicable"]},
|
||||
"evidence_refs": {"type": "array", "maxItems": 1000, "uniqueItems": true, "items": {"$ref": "#/$defs/id"}},
|
||||
"note": {"type": ["string", "null"], "maxLength": 4000}
|
||||
"status": {
|
||||
"enum": ["evidenced", "unresolved", "unsupported", "not_applicable"]
|
||||
},
|
||||
"evidence_refs": {
|
||||
"type": "array",
|
||||
"maxItems": 1000,
|
||||
"uniqueItems": true,
|
||||
"items": { "$ref": "#/$defs/id" }
|
||||
},
|
||||
"note": { "type": ["string", "null"], "maxLength": 4000 }
|
||||
}
|
||||
},
|
||||
"handoffRecord": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"handoff_record_id", "record_type", "intent", "target_id", "review_state",
|
||||
"source_candidate_ids", "observed_instance_ids", "fields", "relationships", "mechanics"
|
||||
"handoff_record_id",
|
||||
"record_type",
|
||||
"intent",
|
||||
"target_id",
|
||||
"review_state",
|
||||
"source_candidate_ids",
|
||||
"observed_instance_ids",
|
||||
"fields",
|
||||
"relationships",
|
||||
"mechanics"
|
||||
],
|
||||
"properties": {
|
||||
"handoff_record_id": {"$ref": "#/$defs/id"}, "record_type": {"$ref": "#/$defs/recordType"},
|
||||
"intent": {"enum": ["create_draft", "update_draft", "abstain"]},
|
||||
"target_id": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/id"}]},
|
||||
"review_state": {"enum": ["approved", "abstained"]},
|
||||
"source_candidate_ids": {"type": "array", "minItems": 1, "maxItems": 100, "uniqueItems": true, "items": {"$ref": "#/$defs/id"}},
|
||||
"observed_instance_ids": {"type": "array", "minItems": 1, "maxItems": 100, "uniqueItems": true, "items": {"$ref": "#/$defs/id"}},
|
||||
"fields": {"type": "array", "maxItems": 1000, "items": {"$ref": "#/$defs/fieldClaim"}},
|
||||
"relationships": {"$ref": "#/$defs/evidenceSection"},
|
||||
"mechanics": {"$ref": "#/$defs/evidenceSection"}
|
||||
"handoff_record_id": { "$ref": "#/$defs/id" },
|
||||
"record_type": { "$ref": "#/$defs/recordType" },
|
||||
"intent": { "enum": ["create_draft", "update_draft", "abstain"] },
|
||||
"target_id": {
|
||||
"oneOf": [{ "type": "null" }, { "$ref": "#/$defs/id" }]
|
||||
},
|
||||
"review_state": { "enum": ["approved", "abstained"] },
|
||||
"source_candidate_ids": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 100,
|
||||
"uniqueItems": true,
|
||||
"items": { "$ref": "#/$defs/id" }
|
||||
},
|
||||
"observed_instance_ids": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 100,
|
||||
"uniqueItems": true,
|
||||
"items": { "$ref": "#/$defs/id" }
|
||||
},
|
||||
"fields": {
|
||||
"type": "array",
|
||||
"maxItems": 1000,
|
||||
"items": { "$ref": "#/$defs/fieldClaim" }
|
||||
},
|
||||
"relationships": { "$ref": "#/$defs/evidenceSection" },
|
||||
"mechanics": { "$ref": "#/$defs/evidenceSection" }
|
||||
},
|
||||
"oneOf": [
|
||||
{"properties": {"intent": {"const": "create_draft"}, "target_id": {"const": null}, "review_state": {"const": "approved"}}},
|
||||
{"properties": {"intent": {"const": "update_draft"}, "target_id": {"$ref": "#/$defs/id"}, "review_state": {"const": "approved"}}},
|
||||
{"properties": {"intent": {"const": "abstain"}, "target_id": {"const": null}, "review_state": {"const": "abstained"}, "fields": {"maxItems": 0}}}
|
||||
{
|
||||
"properties": {
|
||||
"intent": { "const": "create_draft" },
|
||||
"target_id": { "const": null },
|
||||
"review_state": { "const": "approved" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"intent": { "const": "update_draft" },
|
||||
"target_id": { "$ref": "#/$defs/id" },
|
||||
"review_state": { "const": "approved" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"intent": { "const": "abstain" },
|
||||
"target_id": { "const": null },
|
||||
"review_state": { "const": "abstained" },
|
||||
"fields": { "maxItems": 0 }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,86 +4,202 @@
|
||||
"title": "Screenshot classification v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["family", "view", "gear_slot", "record_types", "confidence", "abstention_reason"],
|
||||
"required": [
|
||||
"family",
|
||||
"view",
|
||||
"gear_slot",
|
||||
"record_types",
|
||||
"confidence",
|
||||
"abstention_reason"
|
||||
],
|
||||
"properties": {
|
||||
"family": {"enum": ["weapon", "gear", "skill_chip", "os_protocol", "specialization", "weapon_mod", "gear_mod", "unknown"]},
|
||||
"view": {"type": "string"},
|
||||
"gear_slot": {"type": ["string", "null"]},
|
||||
"record_types": {"type": "array", "uniqueItems": true, "maxItems": 8, "items": {"type": "string"}},
|
||||
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
||||
"abstention_reason": {"type": ["string", "null"]}
|
||||
"family": {
|
||||
"enum": [
|
||||
"weapon",
|
||||
"gear",
|
||||
"skill_chip",
|
||||
"os_protocol",
|
||||
"specialization",
|
||||
"weapon_mod",
|
||||
"gear_mod",
|
||||
"unknown"
|
||||
]
|
||||
},
|
||||
"view": { "type": "string" },
|
||||
"gear_slot": { "type": ["string", "null"] },
|
||||
"record_types": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"maxItems": 8,
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"confidence": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"abstention_reason": { "type": ["string", "null"] }
|
||||
},
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"family": {"const": "weapon"},
|
||||
"view": {"enum": ["detail", "blueprint", "crafting", "stat_range", "talent_grid", "augmentation"]},
|
||||
"gear_slot": {"const": null},
|
||||
"record_types": {"minItems": 1, "items": {"enum": ["weapon", "weapon_talent", "weapon_augmentation_profile"]}},
|
||||
"abstention_reason": {"const": null}
|
||||
"family": { "const": "weapon" },
|
||||
"view": {
|
||||
"enum": [
|
||||
"detail",
|
||||
"blueprint",
|
||||
"crafting",
|
||||
"stat_range",
|
||||
"talent_grid",
|
||||
"augmentation"
|
||||
]
|
||||
},
|
||||
"gear_slot": { "const": null },
|
||||
"record_types": {
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"enum": ["weapon", "weapon_talent", "weapon_augmentation_profile"]
|
||||
}
|
||||
},
|
||||
"abstention_reason": { "const": null }
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"family": {"const": "gear"},
|
||||
"view": {"enum": ["detail", "blueprint", "talent", "augmentation"]},
|
||||
"gear_slot": {"enum": ["Mask", "Gloves", "Backpack", "Body Armor", "Holster", "Knee Pads"]},
|
||||
"record_types": {"minItems": 1, "items": {"enum": ["gear", "brand_set", "gear_talent", "gear_augmentation_profile"]}},
|
||||
"abstention_reason": {"const": null}
|
||||
"family": { "const": "gear" },
|
||||
"view": { "enum": ["detail", "blueprint", "talent", "augmentation"] },
|
||||
"gear_slot": {
|
||||
"enum": [
|
||||
"Mask",
|
||||
"Gloves",
|
||||
"Backpack",
|
||||
"Body Armor",
|
||||
"Holster",
|
||||
"Knee Pads"
|
||||
]
|
||||
},
|
||||
"record_types": {
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"enum": [
|
||||
"gear",
|
||||
"brand_set",
|
||||
"gear_talent",
|
||||
"gear_augmentation_profile"
|
||||
]
|
||||
}
|
||||
},
|
||||
"abstention_reason": { "const": null }
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"family": {"const": "skill_chip"},
|
||||
"view": {"enum": ["detail", "set_bonus", "talent"]},
|
||||
"gear_slot": {"const": null},
|
||||
"record_types": {"minItems": 1, "items": {"enum": ["skill_chip_set", "skill_chip_set_bonus", "skill_chip_roll_pool", "skill_chip_talent"]}},
|
||||
"abstention_reason": {"const": null}
|
||||
"family": { "const": "skill_chip" },
|
||||
"view": { "enum": ["detail", "set_bonus", "talent"] },
|
||||
"gear_slot": { "const": null },
|
||||
"record_types": {
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"enum": [
|
||||
"skill_chip_set",
|
||||
"skill_chip_set_bonus",
|
||||
"skill_chip_roll_pool",
|
||||
"skill_chip_talent"
|
||||
]
|
||||
}
|
||||
},
|
||||
"abstention_reason": { "const": null }
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"family": {"const": "os_protocol"},
|
||||
"view": {"enum": ["detail", "secondary_profile", "talent"]},
|
||||
"gear_slot": {"const": null},
|
||||
"record_types": {"minItems": 1, "items": {"enum": ["os_protocol", "os_protocol_secondary_profile", "os_protocol_talent"]}},
|
||||
"abstention_reason": {"const": null}
|
||||
"family": { "const": "os_protocol" },
|
||||
"view": { "enum": ["detail", "secondary_profile", "talent"] },
|
||||
"gear_slot": { "const": null },
|
||||
"record_types": {
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"enum": [
|
||||
"os_protocol",
|
||||
"os_protocol_secondary_profile",
|
||||
"os_protocol_talent"
|
||||
]
|
||||
}
|
||||
},
|
||||
"abstention_reason": { "const": null }
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"family": {"const": "specialization"},
|
||||
"view": {"enum": ["overview", "details", "ability", "focus_details", "focus_tree", "rank_details"]},
|
||||
"gear_slot": {"const": null},
|
||||
"record_types": {"minItems": 1, "items": {"enum": ["specialization", "specialization_focus", "specialization_ability", "specialization_tree_node", "specialization_tree_rank"]}},
|
||||
"abstention_reason": {"const": null}
|
||||
"family": { "const": "specialization" },
|
||||
"view": {
|
||||
"enum": [
|
||||
"overview",
|
||||
"details",
|
||||
"ability",
|
||||
"focus_details",
|
||||
"focus_tree",
|
||||
"rank_details"
|
||||
]
|
||||
},
|
||||
"gear_slot": { "const": null },
|
||||
"record_types": {
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"enum": [
|
||||
"specialization",
|
||||
"specialization_focus",
|
||||
"specialization_ability",
|
||||
"specialization_tree_node",
|
||||
"specialization_tree_rank"
|
||||
]
|
||||
}
|
||||
},
|
||||
"abstention_reason": { "const": null }
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"family": {"const": "weapon_mod"},
|
||||
"view": {"const": "detail"},
|
||||
"gear_slot": {"const": null},
|
||||
"record_types": {"minItems": 1, "maxItems": 1, "items": {"const": "weapon_mod"}},
|
||||
"abstention_reason": {"const": null}
|
||||
"family": { "const": "weapon_mod" },
|
||||
"view": { "const": "detail" },
|
||||
"gear_slot": { "const": null },
|
||||
"record_types": {
|
||||
"minItems": 1,
|
||||
"maxItems": 1,
|
||||
"items": { "const": "weapon_mod" }
|
||||
},
|
||||
"abstention_reason": { "const": null }
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"family": {"const": "gear_mod"},
|
||||
"view": {"const": "detail"},
|
||||
"gear_slot": {"const": null},
|
||||
"record_types": {"minItems": 1, "maxItems": 1, "items": {"const": "gear_mod"}},
|
||||
"abstention_reason": {"const": null}
|
||||
"family": { "const": "gear_mod" },
|
||||
"view": { "const": "detail" },
|
||||
"gear_slot": { "const": null },
|
||||
"record_types": {
|
||||
"minItems": 1,
|
||||
"maxItems": 1,
|
||||
"items": { "const": "gear_mod" }
|
||||
},
|
||||
"abstention_reason": { "const": null }
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"family": {"const": "unknown"},
|
||||
"view": {"const": "unknown"},
|
||||
"gear_slot": {"const": null},
|
||||
"record_types": {"maxItems": 0},
|
||||
"abstention_reason": {"enum": ["low_confidence", "ambiguous_family", "ambiguous_view", "unsupported_family", "unsupported_view", "insufficient_visual_evidence", "occluded", "corrupt_image", "resource_limit", "cancelled"]}
|
||||
"family": { "const": "unknown" },
|
||||
"view": { "const": "unknown" },
|
||||
"gear_slot": { "const": null },
|
||||
"record_types": { "maxItems": 0 },
|
||||
"abstention_reason": {
|
||||
"enum": [
|
||||
"low_confidence",
|
||||
"ambiguous_family",
|
||||
"ambiguous_view",
|
||||
"unsupported_family",
|
||||
"unsupported_view",
|
||||
"insufficient_visual_evidence",
|
||||
"occluded",
|
||||
"corrupt_image",
|
||||
"resource_limit",
|
||||
"cancelled"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user