CTI-Transmute — Developer Documentation
Complete reference for the CTI conversion platform — architecture, database models, REST API, web routes, and frontend patterns.
Flask 3 Vue 3 (no build) PostgreSQL Pivotick SQLAlchemy + Flask-Migrate Flask-Login + WTForms

Overview

CTI-Transmute is a web platform that converts threat intelligence data between MISP and STIX 2.x formats. The conversion engine is Pivotick, a separate service running on port 6868. The web application wraps it with persistent storage, social features, a tagging system, and interactive visualizations.

Flask 3
Backend framework, blueprints, routes
Vue 3
CDN global build, no compile step
PostgreSQL
SQLAlchemy ORM, Flask-Migrate
Pivotick
Conversion engine, git submodule
ECharts
Sunburst & treemap charts
Bootstrap 5.3
+ FontAwesome 6.3 + hljs 11.8
Two conversion directions
MISP → STIX 2.1 and STIX → MISP. Each result is stored and versioned.
Social layer
Comments, reactions, follow system, notifications, and community reporting.
Tag system
12 380+ MISP taxonomy tags, galaxy tags, and custom user tags with admin approval.
Evaluation
Per-convert quality scoring with like/dislike, reaction tags, and PDF/Markdown export.

Quick Start

All commands are run via uv from the repo root.

First-time setup
# Clone with submodules
git clone --recurse-submodules <repo>

# First-time: init submodules + create DB + apply migrations
uv run manage init

# Start everything
uv run manage start
Day-to-day commands
uv run manage start        # start the app
uv run manage update       # git pull + deps + migrations
uv run manage backup       # pg_dump to website/db_class/backups/
uv run manage db migrate   # create a new migration
uv run manage db upgrade   # apply pending migrations
Configuration

Edit config/generic.json:

config/generic.json
{
    "listen_ip": "0.0.0.0",
    "listen_port": 5000
}

Database connection is hardcoded in website/web/__init__.py:

postgresql+psycopg2://cti_user:cti_pass@localhost:5432/cti_db

Architecture

Browser
Vue 3 + Bootstrap
Flask App
:5000
PostgreSQL
:5432
Pivotick
:6868 (internal)
Blueprint structure
Each feature is a folder with 3 files: feature.py (routes), feature_core.py (DB/logic), feature_form.py (WTForms).
Entry point
bin/start_website.py registers all blueprints and calls application.run().
Conversion flow
Flask receives JSON → proxies to Pivotick at :6868 → stores result in DB → returns to Vue.
Submodules
vendor/pivotick, vendor/misp-taxonomies, vendor/misp-galaxy — all git submodules.
Project layout
bin/
  manage.py              # CLI: start / init / update / backup / db
  start_website.py       # registers blueprints → application.run()
config/
  generic.json           # listen_ip, listen_port
cti_transmute/           # Transmute class wrapper
vendor/                  # git submodules
website/
  web/__init__.py        # Flask factory, db/login/session/migrate
  web/home.py            # home_blueprint
  web/convert/           # convert blueprint + core + form
  web/evaluate/          # evaluate blueprint + core
  web/tags/              # tags blueprint + core
  web/account/           # account blueprint
  web/utils.py           # shared helpers
  web/templates/         # Jinja2 (base.html + per-feature folders)
  web/static/
    css/                 # one .css per feature
    js/graph/            # convert visualizations
    js/misp/             # MISP push modal
    js/tags/             # tag components
    js/evaluate/         # evaluation charts
  db_class/db.py         # all SQLAlchemy models
  migrations/            # Flask-Migrate / Alembic
  api/convert.py         # REST API blueprint (:6868)

Database Models

All models live in website/db_class/db.py. Every model exposes a to_json() method. Soft deletes use is_active = False + optional deleted_at. Every main model has a uuid column (String 36).

User
User Authentication, roles, API access
FieldTypeNotes
idInteger PKAuto-increment primary key
uuidString(36)UUID4, unique
first_name / last_nameString(64)Display name
emailString(64)Unique, used for login
passwordString(300)bcrypt hash
roleString(36)"user" or "admin"
api_keyString(60)Bearer token for REST API
is_connectedBooleanOnline status flag
created_at / last_seenDateTimeUTC timestamps
Convert
Convert Core conversion record
FieldTypeNotes
id / uuidInteger / String(36)PK + UUID4
nameString(256)User-defined label
descriptionTextOptional free text
conversion_typeString(32)"MISP_TO_STIX" or "STIX_TO_MISP"
input_text / output_textTextRaw JSON strings
publicBooleanVisibility flag
share_keyString(64)Token for sharing private converts
is_activeBooleanSoft-delete flag
user_idFK → UserOwner
created_at / updated_at / deleted_atDateTimeUTC timestamps
ConvertHistory Version tracking — a snapshot before reconversion
FieldTypeNotes
versionIntegerAuto-incremented per convert
statusString"pending" | "accepted" | "rejected"
input_text / output_textTextSnapshot of the conversion at that version
commentTextOptional change note
convert_idFK → ConvertParent convert
Comments & Reactions
Comment Discussion thread + evaluation comments on a convert
FieldTypeNotes
contentTextMax 2000 chars
is_privateBooleanOnly visible to author + admins
is_evaluationBooleanShows under Evaluation tab instead of Discussion
is_deletedBooleanSoft-delete, content replaced with placeholder
parent_idFK → CommentSelf-referential for replies (1 level)
convert_id / user_idFKOwner and target
Tags
Tag Taxonomy / Galaxy / Manual tags with admin approval
FieldTypeNotes
nameString(256)Unique tag name (e.g. tlp:red)
colourString(7)Hex color code
iconString(64)FontAwesome class name
source_typeString(32)"Manual" | "Taxonomy" | "Vulnerability"
is_approved / is_activeBooleanAdmin approval workflow
is_publicBooleanVisible to non-admin users
is_evaluation_tagBooleanAvailable as reaction in evaluation voting
ConvertTagAssociation Bridge: many tags ↔ many converts
FieldTypeNotes
convert_id / tag_idFKComposite unique constraint
added_byString"user" or "auto-scan"
Social & Evaluation
ConvertEvaluation Like / dislike / reaction votes per convert
FieldTypeNotes
vote_typeString"like" | "dislike" | "reaction"
reaction_keyStringTag name used as evaluation reaction
convert_id / user_idFKUnique per (user, convert, vote_type)
Notification In-app notifications for social events
FieldTypeNotes
notif_typeStringcomment_reply, new_follow_convert, report_submitted, …
is_readBooleanUnread badge count in navbar
target_id / target_typeInteger / StringPolymorphic target (convert, comment, …)
user_idFK → UserRecipient
UserFollow / ConvertFavorite / ConvertReport / SystemLog / GraphConfig / PlatformReview
ModelPurpose
UserFollowfollower_id → followed_id social graph
ConvertFavoriteBookmarked converts per user
ConvertReportAbuse reports: reason + description + status (pending / reviewed / dismissed)
SystemLogAudit log — event_type, actor_name, target_id/type, details, created_at
GraphConfigSaved Pivotick visualization configs per user
PlatformReview1–5 star platform rating + optional text per user

REST API

The conversion REST API runs on port 6868 via the Pivotick service. It is also proxied internally by Flask — call it from Flask at http://127.0.0.1:6868/api/convert/....

Authentication is via Bearer token: send the user's api_key in the Authorization header. Some endpoints are public.
List available converters
GET
/api/convert/list

Returns the list of all available conversion directions and their parameters.

Public
Response
{
    "available": {
        "misp_to_stix": {
            "version": ["2.1"],
            "description": "Convert a MISP event to a STIX 2.1 bundle"
        },
        "stix_to_misp": {
            "params": ["distribution", "galaxies_as_tags", "single_event", "..."],
            "description": "Convert a STIX bundle to a MISP event"
        }
    }
}
MISP → STIX
POST
/api/convert/misp_to_stix?version=2.1

Convert a MISP JSON event to a STIX 2.1 bundle. Body is the raw MISP JSON.

Auth
Request body (MISP event)
{
    "Event": {
        "info": "Phishing campaign targeting finance sector",
        "uuid": "5e9f1234-1234-1234-1234-1234567890ab",
        "date": "2024-01-15",
        "threat_level_id": "2",
        "Attribute": [
            {
                "category": "Network activity",
                "type": "ip-dst",
                "value": "198.51.100.42",
                "to_ids": true,
                "uuid": "5e9f1234-aaaa-bbbb-cccc-1234567890ab"
            },
            {
                "category": "Payload delivery",
                "type": "sha256",
                "value": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
                "to_ids": true
            }
        ],
        "Tag": [
            { "name": "tlp:amber" }
        ]
    }
}
Response (STIX bundle)
{
    "type": "bundle",
    "id": "bundle--a1b2c3d4-1234-5678-abcd-ef0123456789",
    "objects": [
        {
            "type": "report",
            "id": "report--...",
            "name": "Phishing campaign targeting finance sector",
            "published": "2024-01-15T00:00:00Z",
            "object_refs": ["indicator--...", "marking-definition--..."]
        },
        {
            "type": "indicator",
            "id": "indicator--...",
            "pattern": "[ipv4-addr:value = '198.51.100.42']",
            "pattern_type": "stix",
            "valid_from": "2024-01-15T00:00:00Z"
        },
        {
            "type": "marking-definition",
            "id": "marking-definition--...",
            "definition_type": "tlp",
            "definition": { "tlp": "amber" }
        }
    ]
}
STIX → MISP
POST
/api/convert/stix_to_misp

Convert a STIX 2.x bundle to a MISP event. All params are query strings.

Auth
ParameterTypeDefaultDescription
distributionint 0–40MISP distribution level (0=org, 1=community, 2=connected, 3=all, 4=sharing group)
sharing_group_idintRequired when distribution=4
galaxies_as_tagsflagoffConvert STIX galaxies to MISP tags instead of galaxy clusters
no_force_contextual_dataflagoffDisable forced contextual data attachment
cluster_distributionint 0–40Distribution for galaxy clusters
organisation_uuidstringUUID of the organisation to attach the event to
single_eventflagoffForce a single MISP event output
producerstringProducer name tag
titlestringOverride the event title
Response (MISP event)
{
    "Event": {
        "info": "STIX Report: ...",
        "uuid": "...",
        "distribution": 0,
        "threat_level_id": 2,
        "Attribute": [ { "type": "ip-dst", "value": "198.51.100.42", "..." : "..." } ],
        "Object": [],
        "Tag": []
    }
}

Web Routes — Convert

All routes under /convert. JSON routes return {"success": bool, "message": "...", "toast_class": "..."}.

Standard JSON response shape
{ "success": true,  "data": { }, "message": "Done", "toast_class": "success" }
{ "success": false, "message": "Error text",       "toast_class": "danger"  }
Conversion
POST
/convert/misp_to_stix

Submit a MISP JSON for conversion. Params: stix_version, name, description, public.

Auth
POST
/convert/stix_to_misp

Submit a STIX bundle. Same params plus all STIX→MISP options (distribution, galaxies_as_tags, etc.).

Auth
POST
/convert/fetch_misp_event

Fetch a MISP event from a remote MISP instance via restSearch. Body: misp_url, misp_key, event_id.

Auth
POST
/convert/misp_search_events

Search events on a remote MISP by info/tag/date. Returns a list of matching events.

Auth
GET
/convert/refresh/<uuid>

Re-run a conversion with the original input and show a diff preview.

Auth
POST
/convert/refresh

Accept or reject a pending history version. Body: history_id, action ("accept" | "reject").

Auth
History & browsing
GET
/convert/history

History listing page (Vue SPA shell).

Public
GET
/convert/get_convert_page_history

Paginated JSON list. Filters: type, sort, date_from/to, tags[], favorites_only, search, search_scope.

Public
GET
/convert/get_convert?id=<id>

Get one convert by DB id or UUID. Returns full object including input/output text.

Public
GET
/convert/detail/<id>

Detail page with all tabs (Input, Output, Graph, Sunburst, Table, Comments, Evaluation).

Public
GET
/convert/difference/<id>

Side-by-side diff of a history version vs the current output.

Auth
GET
/convert/search_in_content?q=<q>

Full-text search with snippet extraction. Returns matching convert IDs + context snippets.

Public
Sharing & visibility
GET
/convert/edit_public?id=<id>

Toggle public/private. Returns new state.

Auth
GET
/convert/get_share_key?id=<id>

Retrieve the share token for a private convert.

Owner
GET
/convert/regenerate_share_key?id=<id>

Rotate the share key, invalidating old share links.

Owner
GET
/convert/share?uuid=<u>&share_key=<k>

Access a private convert via share link. Redirects to detail if valid.

Public
POST
/convert/delete_item

Soft-delete. Body: {"id": 42}. Moves to admin trash.

Owner
Comments & reactions
GET
/convert/get_comments?convert_id=<id>

Fetch all comments for a convert. Filters private/deleted based on requester role.

Public
POST
/convert/comment

Create a comment or reply. Body: convert_id, content, is_private, is_evaluation, optional parent_id.

Auth
POST
/convert/edit_comment

Update comment content. Body: comment_id, content.

Auth
GET
/convert/delete_comment?comment_id=<id>

Soft-delete a comment. Content replaced with placeholder.

Auth
GET
/convert/toggle_comment_private?comment_id=<id>

Toggle a comment between public and private.

Auth
POST
/convert/react

Toggle emoji reaction. Body: comment_id, emoji. Whitelist: 👍 😊 ❤️ 🎯 ⚠️

Auth
Favorites, downloads & MISP push
POST
/convert/favorite/toggle

Bookmark or un-bookmark a convert. Body: {"convert_id": 42}.

Auth
GET
/convert/favorite/status/<id>

Check whether the current user has favorited a convert.

Auth
GET
/convert/download/<id>/input

Download the raw input JSON file.

Public
GET
/convert/download/<id>/output

Download the conversion result JSON.

Public
GET
/convert/download/<id>/misp-push

Download the full PyMISP push payload including evaluation tags object.

Auth
POST
/convert/push_to_misp

Push to a remote MISP. Body: misp_url, misp_key, convert_id.

Auth
POST
/convert/report

Submit an abuse report. Body: convert_id, reason (spam/inappropriate/inaccurate/other), description.

Auth
Admin — trash & moderation
GET
/convert/trash

Admin trash page listing soft-deleted converts.

Admin
POST
/convert/restore

Restore a soft-deleted convert. Body: {"id": 42}.

Admin
POST
/convert/hard_delete

Permanently delete a convert. Irreversible.

Admin
POST
/convert/bulk_action

Restore or hard-delete multiple converts. Body: {"ids": [...], "action": "restore"}.

Admin
GET
/convert/admin/get_reports

Paginated list of abuse reports with filters.

Admin
GET
/convert/admin/review_report?id=<id>&action=<a>

Mark a report as reviewed or dismissed.

Admin

Web Routes — Evaluate

All routes under /evaluate. Provides quality scoring and export for conversions.

GET
/evaluate/summary/<id>

Evaluation stats for a convert: like/dislike counts, reaction breakdown, consensus score.

Public
POST
/evaluate/toggle_like

Like a convert. Body: {"convert_id": 42}. Toggle: calling twice removes the like.

Auth
POST
/evaluate/toggle_dislike

Dislike a convert. Mutually exclusive with like.

Auth
POST
/evaluate/toggle_reaction

Vote with a reaction tag. Body: {"convert_id": 42, "reaction_key": "tlp:red"}.

Auth
GET
/evaluate/consensus_tags/<id>

Returns tags that reached the vote threshold (default ≥3). Used in MISP push payload.

Public
GET
/evaluate/export/<id>/markdown

Download evaluation report as a .md file.

Public
GET
/evaluate/export/<id>/pdf

Download evaluation report as a PDF.

Public
GET
/evaluate/global_stats

Platform-wide evaluation stats.

Public
GET
/evaluate/activity_timeline?days=<n>

Evaluation activity over the last N days (chart data).

Public
POST
/evaluate/platform_review

Submit a 1–5 star platform review. Body: {"score": 5, "content": "..."}.

Auth
GET
/evaluate/admin/list

Admin: paginated list of all evaluations with filters.

Admin

Web Routes — Tags

All routes under /tags. Tags have three sources: Manual, Taxonomy (~12 380 from MISP), and Vulnerability.

User-facing
POST
/tags/create

Create a custom tag. Auto-approved for admins creating Vulnerability tags.

Auth
GET
/tags/list

Paginated list of tags accessible to the current user.

Public
GET
/tags/available

Tags available for attachment. Filter ?evaluation=1 for eval-only tags.

Public
GET
/tags/for_convert/<id>

Tags currently attached to a convert (filtered by source_type if provided).

Public
POST
/tags/save_for_convert/<id>

Replace all tags on a convert. Body: {"tag_ids": [1, 2, 3]}.

Auth
GET
/tags/extract_from_convert/<id>

Auto-extract tag names from the stored JSON and match them against the tag library.

Auth
Admin
GET
/tags/admin/list

Paginated all tags with approval workflow. Filter by source, active, approved, public.

Admin
POST
/tags/admin/approve/<id>

Toggle approval status of a tag.

Admin
POST
/tags/admin/import_taxonomies

Import all tags from vendor/misp-taxonomies submodule (~12 380 tags).

Admin
POST
/tags/admin/import_galaxies

Import cluster names from vendor/misp-galaxy submodule.

Admin
POST
/tags/admin/bulk_converts/scan

Background job: scan all converts and auto-assign matching tags from their JSON.

Admin
GET
/tags/admin/bulk_converts/job/<id>

Poll the status of a background bulk-tag job.

Admin

Web Routes — Account

All routes under /account.

Authentication & profile
POST
/account/login

Login with email + password. Returns a session cookie.

Public
POST
/account/register

Create an account. Validates email uniqueness and password strength.

Public
GET
/account/logout

Destroy session and redirect to home.

Auth
POST
/account/edit

Update first/last name, email, password.

Auth
Notifications & following
GET
/account/get_notification_count

Returns unread notification count for the navbar badge.

Auth
GET
/account/get_notifications

Paginated notifications. Filter ?unread_only=1, ?search=.

Auth
GET
/account/mark_all_read

Mark all unread notifications as read.

Auth
GET
/account/follow?user_id=<id>

Follow or unfollow a user. Toggle endpoint.

Auth
GET
/account/is_following?user_id=<id>

Check if the current user follows a given user.

Auth
Admin — users & audit logs
GET
/account/get_users

Paginated user list. Filters: connection status, admin role, search.

Admin
POST
/account/edit_admin

Grant or revoke admin rights. Body: {"user_id": 5, "is_admin": true}.

Admin
POST
/account/delete/<id>

Delete a user and cascade-delete all their converts and comments.

Admin
GET
/account/admin/get_all_notifications

Merged view of notifications + system logs. Filters: type, date range, search.

Admin
POST
/account/admin/delete_logs_bulk

Bulk-delete logs by ID list.

Admin

Frontend — Vue 3

Vue 3 is loaded as a global CDN build from /static/js/vue.global.js. There is no build step — components are plain .js ES modules with inline template strings. The Jinja2 delimiter conflict is resolved by using [[ ]] instead of {{ }} in Vue templates.

Component skeleton
myComponent.js
const MyComponent = {
    delimiters: ['[[', ']]'],   // avoid conflict with Jinja2 {{ }}
    props: {
        convertData: { type: Object, default: null },
    },
    components: { 'json-viewer': JsonViewer },
    template: `
<div class="mc-wrapper">
    [[ convertData?.name ]]
</div>`,
    setup(props) {
        const { ref, watch } = Vue
        // ...
        watch(() => props.convertData, v => {
            if (v) doWork()
        }, { immediate: true })  // fires immediately on mount

        return { /* exposed to template */ }
    },
}
export default MyComponent
Mounting in a Jinja template
detail.html (script block)
import MyComponent from '/static/js/graph/myComponent.js'

const app = createApp({ delimiters: ['[[',']]'], setup() { /* ... */ } })
app.component('my-component', MyComponent)
app.mount('#main-containers')
CSRF token (for POST requests)
fetch pattern
const csrfToken = document.getElementById('csrf_token')?.value

const res = await fetch('/convert/comment', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
    body: JSON.stringify({ convert_id: 42, content: 'Great conversion!' }),
})
const data = await res.json()
if (data.success) create_message(data.message, data.toast_class)

Component Inventory

All reusable Vue components, their file paths, and what they do.

Component
File
Description
ConvertSunburstjs/graph/convertSunburst.jsECharts sunburst + treemap of CTI data by category/type. Click a slice → JsonViewer drill-down.
ConvertTablejs/graph/convertTable.jsFlat tabular view with search, sortable columns, pagination (50 rows/page). Click a row → JsonViewer.
JsonViewerjs/graph/jsonViewer.jsReusable JSON display: hljs syntax highlighting, collapsible tree, format, download, copy.
ConvertGraphjs/graph/convertGraph.jsForce-directed graph via Pivotick. Input/Output toggle. Config modal via graphConfigModal.js.
EvaluationPaneljs/evaluationPanel.jsLike / dislike / reaction-tag voting panel per convert.
EvaluationChartsjs/evaluate/evaluationCharts.jsAggregated vote charts for a single convert's evaluation tab.
PushConvertToMISPjs/misp/pushConvertToMISP.jsModal: configure MISP URL + key and push the conversion with evaluation tags.
TagInputjs/tags/tagInput.jsTag selector + display per convert. Uses Select2-style dropdown with search.
Detail page tab system

The detail page (templates/convert/detail.html) uses Bootstrap 5 tabs. Each tab is a nav-link + a matching tab-pane. The visualization components receive the convert object as :convert-data="convert" and auto-render when the prop becomes available via watch({ immediate: true }).

Tab
Pane ID pattern
Lazy?
Input#input-pane-{id}hljs-highlighted JSON, Tree toggle, Format, Download, Copy
Output#output-pane-{id}Same as Input for the conversion result
History#compare-pane-{id}Version table, click row → /convert/difference/{id}
Graph#graph-pane-{id}Pivotick force-directed graph, loaded on tab click
Sunburst#sunburst-pane-{id}<convert-sunburst> — ECharts, lazy CDN load
Table#table-pane-{id}<convert-table> — pure JS parse + render
Comments#comments-pane-{id}Discussion thread, loaded on first tab click
Evaluation#eval-pane-{id}EvaluationPanel + EvaluationCharts + eval comments

Adding a New Visualization

All visualization components follow the same pattern. They live in website/web/static/js/graph/, receive convertData as a prop, and parse JSON client-side — no extra API call needed.

1
Create the component file
Create website/web/static/js/graph/myViz.js. The component receives convertData (the full convert object with input_text and output_text fields). Use watch({ immediate: true }) so it auto-renders as soon as the prop is populated — even before the user clicks the tab.
2
Detect the format and parse
Use the shared detection pattern. MISP JSON contains "Event" or "Attribute". STIX bundles contain "type":"bundle". Parse synchronously, defer large work with setTimeout(fn, 0) so the spinner renders first.
3
Lazy-load any external library
Use the singleton promise pattern — store the promise in a module-level variable so the CDN script is only appended once regardless of how many times the component mounts or renders.
4
Handle resize and theme changes
Listen to window resize and document.documentElement themechange. Use a ResizeObserver on the chart container to handle the case where the chart initialised while the tab was hidden (zero dimensions).
5
Add the tab in detail.html
Add a <li class="nav-item"> with data-bs-toggle="tab" pointing to #myviz-pane-[[convert.id]]. Add the matching tab-pane below. Place <my-viz :convert-data="convert"></my-viz> inside it. Import and register the component in the <script type="module"> block. Add CSS with a unique class prefix to detail.css.
Minimal visualization template
import JsonViewer from '/static/js/graph/jsonViewer.js'

// Singleton CDN loader
let _libPromise = null
function loadLib() {
    if (window.myLib) return Promise.resolve(window.myLib)
    if (_libPromise)  return _libPromise
    _libPromise = new Promise((resolve, reject) => {
        const s = document.createElement('script')
        s.src = 'https://cdn.example.com/mylib.min.js'
        s.onload  = () => resolve(window.myLib)
        s.onerror = () => reject(new Error('CDN load failed'))
        document.head.appendChild(s)
    })
    return _libPromise
}

function detectFormat(text) {
    if (!text) return 'unknown'
    const s = text.trimStart()
    if (s.includes('"type":"bundle"') || s.includes('"type": "bundle"')) return 'stix'
    if (s.includes('"Event"') || s.includes('"Attribute"')) return 'misp'
    return 'unknown'
}

const MyViz = {
    delimiters: ['[[', ']]'],
    components: { 'json-viewer': JsonViewer },
    props: { convertData: { type: Object, default: null } },
    template: `
<div class="mvz-wrapper">
    <div class="mvz-toolbar">
        <button :class="{active: side==='input'}"  @click="setSide('input')">Input</button>
        <button :class="{active: side==='output'}" @click="setSide('output')">Output</button>
    </div>
    <div v-if="loading"><!-- spinner --></div>
    <div v-else-if="error">[[ error ]]</div>
    <div v-else ref="chartEl" class="mvz-chart"></div>
</div>`,

    setup(props) {
        const { ref, watch, onUnmounted } = Vue
        const side    = ref('input')
        const loading = ref(false)
        const error   = ref('')
        const chartEl = ref(null)
        let inst = null, resizeObs = null

        async function render() {
            if (!props.convertData) return
            loading.value = true
            error.value = ''
            const text = side.value === 'input'
                ? props.convertData.input_text
                : props.convertData.output_text
            if (!text) { loading.value = false; return }

            const fmt = detectFormat(text)
            // parse...

            try { await loadLib() } catch { error.value = 'CDN load failed'; loading.value = false; return }
            loading.value = false
            await Vue.nextTick()

            // init chart in chartEl.value...

            if (resizeObs) resizeObs.disconnect()
            resizeObs = new ResizeObserver(() => { if (inst && chartEl.value?.offsetWidth > 0) inst.resize() })
            resizeObs.observe(chartEl.value)
        }

        function setSide(s) { side.value = s; render() }

        window.addEventListener('resize', () => inst?.resize())
        document.documentElement.addEventListener('themechange', render)
        onUnmounted(() => {
            window.removeEventListener('resize', () => inst?.resize())
            document.documentElement.removeEventListener('themechange', render)
            resizeObs?.disconnect()
            inst?.dispose?.()
        })

        watch(() => props.convertData, v => { if (v?.input_text || v?.output_text) render() }, { immediate: true })
        return { side, loading, error, chartEl, setSide }
    },
}
export default MyViz

CSS & Theming

The app supports 4 themes — light, dark, dusk, ocean — applied as a class on <html> (e.g. html.dark-mode). All color values are CSS custom properties defined in core.css.

Core design tokens
--bgPage background
--surfaceCard / panel background
--surface-2Secondary surface (table headers, toolbars)
--borderDefault border color
--accentPrimary interactive color (blue)
--accent-lightFaint accent background (hover states)
--textPrimary text
--text-2Secondary / muted text
--text-3Disabled / placeholder text
--success / --danger / --warningSemantic colors
--font-uiInter (UI text)
--font-monoJetBrains Mono (code)
--radius-sm / --radius-md / --radius-lg6px / 10px / 14px
--shadow-sm / --shadow-md / --shadow-lgElevation shadows
--tTransition shorthand — 0.18s ease
Theme change event

When the user switches theme, the app dispatches a custom event on document.documentElement. Use this to re-render charts or swap hljs themes.

Listen for theme change
document.documentElement.addEventListener('themechange', (e) => {
    const theme = e.detail?.theme  // 'light' | 'dark' | 'dusk' | 'ocean'
    const isDark = !['light', 'ocean'].includes(theme)
    // re-render charts, swap hljs stylesheet, etc.
})
CSS class prefix convention

Each Vue component uses a unique 3–4 character prefix for all its CSS classes to prevent collisions with Bootstrap and other components.

Prefix
Component
File
csb-ConvertSunburstdetail.css
ctbl-ConvertTabledetail.css
jv-JsonViewerdetail.css
jt-JSON Tree (inline)detail.css — shared by JsonViewer + raw viewer
dc-Docs pagedocs.css