]> git.ipfire.org Git - thirdparty/google/fonts.git/commitdiff
Add Aaron's quality pages main
authorMarc Foley <m.foley.88@gmail.com>
Thu, 18 Jun 2026 14:15:56 +0000 (15:15 +0100)
committerMarc Foley <m.foley.88@gmail.com>
Thu, 18 Jun 2026 14:15:56 +0000 (15:15 +0100)
.ci/quality_gaps_analysis.html [new file with mode: 0644]
.ci/quality_outperforming.html [new file with mode: 0644]
.ci/quality_overview.html [new file with mode: 0644]
.ci/quality_perception.html [new file with mode: 0644]
.ci/quality_uplevel_analysis.html [new file with mode: 0644]
.ci/quality_usage.html [new file with mode: 0644]

diff --git a/.ci/quality_gaps_analysis.html b/.ci/quality_gaps_analysis.html
new file mode 100644 (file)
index 0000000..c2e515a
--- /dev/null
@@ -0,0 +1,333 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>High Quality Tag Coverage (Score > 70)</title>
+    <style>
+        html, body {
+            height: 100%;
+            margin: 0;
+            overflow: hidden; /* Prevent full page scroll */
+            background: #f8f9fa; 
+            color: #202124;
+            font-family: 'Google Sans', Roboto, sans-serif; 
+        }
+
+        body {
+            display: flex;
+            flex-direction: column;
+            padding: 20px;
+            box-sizing: border-box;
+        }
+
+        h1 { margin-bottom: 5px; }
+        p.subtitle { color: #5f6368; margin-top: 0; margin-bottom: 20px; font-size: 0.9rem; }
+        
+        /* Table Styles */
+        .table-container { overflow-x: auto; box-shadow: 0 1px 3px rgba(0,0,0,0.2); background: white; border-radius: 8px; max-height: 90vh; }
+        table { border-collapse: separate; border-spacing: 0; width: 100%; min-width: 1200px; }
+        
+        /* Headers */
+        th { background-color: #f1f3f4; position: sticky; top: 0; z-index: 10; font-weight: 600; color: #5f6368; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.5px; padding: 10px 8px; border-bottom: 1px solid #ddd; text-align: left; }
+        
+        /* Freeze first column */
+        th:first-child, td:first-child { position: sticky; left: 0; background-color: #fff; z-index: 11; border-right: 2px solid #ddd; min-width: 120px; }
+        th:first-child { z-index: 12; background-color: #f1f3f4; } 
+
+        td { font-size: 0.85rem; padding: 8px; border-bottom: 1px solid #eee; text-align: right; border-right: 1px solid #f0f0f0; }
+        td:first-child { text-align: left; color: #202124; }
+
+        /* Row Highlighting */
+        tr:hover td { background-color: #f1f3f4 !important; }
+        /*tr.highlight-row td { background-color: #fff8e1; } /* Light Amber base */
+        /* tr.highlight-row td:first-child { color: #b06000; } /* Text color for label */
+        
+        /* Ensure frozen column keeps highlight color */
+        tr.highlight-row td:first-child { background-color: #f9cc6b; }
+
+        /* Conditional Highlighting (Cells) */
+        .cell-zero { color: #e0e0e0; }
+        
+        /* Override row color for specific cells */
+
+        td.cell-one { background-color: #fff5e1; color: #b06000; } 
+        
+        tr.highlight-row td.cell-many { background-color: #c8e6c9; } /* Darker green for contrast */
+        td.cell-many { background-color: #e6f4ea; color: #137333; } 
+        
+        /* Interactive Cells */
+        .clickable { cursor: pointer; }
+        .clickable:hover { box-shadow: inset 0 0 0 2px #1a73e8; }
+
+        /* Modal Styles */
+        .modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; justify-content: center; align-items: center; }
+        .modal-active { display: flex; }
+        .modal-content { background: white; width: 90%; max-width: 600px; max-height: 80vh; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); display: flex; flex-direction: column; }
+        .modal-header { padding: 16px 20px; border-bottom: 1px solid #ddd; display: flex; justify-content: space-between; align-items: center; background: #f1f3f4; border-radius: 8px 8px 0 0; }
+        .modal-title { margin: 0; font-size: 1.1rem; color: #202124; }
+        .close-btn { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #5f6368; }
+        .modal-body { padding: 20px; overflow-y: auto; }
+        ul.font-list { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; }
+        ul.font-list li a { text-decoration: none; color: #1a73e8; font-size: 0.95rem; display: block; padding: 4px 8px; border-radius: 4px; }
+        ul.font-list li a:hover { background-color: #e8f0fe; }
+
+        #status { padding: 10px; margin-bottom: 15px; border-radius: 4px; display: none; }
+        .loading { background-color: #e8f0fe; color: #1967d2; display: block !important; }
+        .error { background-color: #fce8e6; color: #c5221f; display: block !important; }
+    </style>
+</head>
+<body>
+
+    <h1>Global Tag Coverage (High Quality Only)</h1>
+    <p class="subtitle">
+        Fonts per Language per Tag. 
+        <strong>Filtered:</strong> Score &ge; 70.
+        <span style="background:#fff8e1; padding:2px 5px; color:#b06000; border-radius:4px;">Highlighted rows</span> are priority scripts.
+    </p>
+
+    <div class="table-container">
+        <table id="dashboard">
+            <thead>
+                </thead>
+            <tbody>
+                </tbody>
+        </table>
+    </div>
+
+    <div id="modal" class="modal-overlay">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h2 id="modal-title" class="modal-title">Fonts List</h2>
+                <button class="close-btn" onclick="closeModal()">&times;</button>
+            </div>
+            <div class="modal-body">
+                <ul id="font-list" class="font-list"></ul>
+            </div>
+        </div>
+    </div>
+
+<script>
+    // --- Configuration ---
+    const METADATA_URL = "catalog_metadata.json"; 
+    const TAGS_URL = "https://raw.githubusercontent.com/google/fonts/refs/heads/main/tags/all/families.csv";
+    
+    // Quality Weights & Threshold
+    const WEIGHTS = { "Concept": 0.4, "Drawing": 0.3, "Spacing": 0.2, "Wordspace": 0.1 };
+    const QUALITY_THRESHOLD = 70;
+
+    const ALLOWED_SUBSETS = [
+        "latin", "devanagari", "japanese", "korean", "arabic", "thai", 
+        "bengali", "chinese-simplified", "cyrillic", "greek", "hebrew", 
+        "tamil", "telugu", "gujarati", "kannada", "malayalam", "gurmukhi", 
+        "oriya", "myanmar", "armenian", "sinhala", "georgian", "chinese-traditional"
+    ];
+
+    const HIGHLIGHT_SUBSETS = [
+        "latin", "cyrillic", "greek", "devanagari", "hebrew", "arabic", 
+        "korean", "japanese", "thai", "bengali", "chinese-simplified"
+    ];
+
+    // --- Main Execution ---
+    document.addEventListener("DOMContentLoaded", async () => {
+        const statusDiv = document.getElementById('status');
+        try {
+            const [metaRes, tagsRes] = await Promise.all([ fetch(METADATA_URL), fetch(TAGS_URL) ]);
+
+            if (!metaRes.ok) throw new Error(`Metadata Error: ${metaRes.status}`);
+            if (!tagsRes.ok) throw new Error(`Tags CSV Error: ${tagsRes.status}`);
+
+            const metadata = await metaRes.json();
+            const tagsText = await tagsRes.text();
+
+            const parsedData = parseCSVFull(tagsText);
+            const { scores, fontTagsMap, uniqueTags } = parsedData;
+
+            const tableData = aggregateData(metadata.familyMetadataList, fontTagsMap, scores, uniqueTags);
+            
+            renderTable(tableData, uniqueTags);
+            statusDiv.style.display = 'none';
+
+        } catch (err) {
+            console.error(err);
+            statusDiv.textContent = `Error: ${err.message}`;
+            statusDiv.className = 'error';
+        }
+    });
+
+    // --- Data Processing ---
+    function parseCSVFull(csvText) {
+        const lines = csvText.split('\n');
+        const fontTagsMap = {}; 
+        const uniqueTags = new Set();
+        const rawQualityValues = {};
+
+        for (let i = 1; i < lines.length; i++) {
+            const row = lines[i].split(',');
+            if (row.length < 3) continue; 
+
+            const family = row[0].trim();
+            let rawTag = row[2].trim(); 
+            let weightStr = (row[3] || "0").trim();
+            if (!rawTag && row[1]) rawTag = row[1].trim();
+            let weight = parseFloat(weightStr);
+
+            if (rawTag.includes("Quality/")) {
+                const parts = rawTag.split("Quality/");
+                const type = parts[1];
+                if (type && !isNaN(weight)) {
+                    if (!rawQualityValues[family]) rawQualityValues[family] = {};
+                    rawQualityValues[family][type] = weight;
+                }
+            } 
+            else {
+                let cleanTag = rawTag.replace(/^[\/"']+|[\/"']+$/g, '');
+                if (cleanTag) {
+                    uniqueTags.add(cleanTag);
+                    if (!fontTagsMap[family]) fontTagsMap[family] = new Set();
+                    fontTagsMap[family].add(cleanTag);
+                }
+            }
+        }
+
+        const scores = {};
+        for (const [family, categories] of Object.entries(rawQualityValues)) {
+            let totalScore = 0;
+            totalScore += (categories['Concept'] || 0) * WEIGHTS['Concept'];
+            totalScore += (categories['Drawing'] || 0) * WEIGHTS['Drawing'];
+            totalScore += (categories['Spacing'] || 0) * WEIGHTS['Spacing'];
+            totalScore += (categories['Wordspace'] || 0) * WEIGHTS['Wordspace'];
+            scores[family] = totalScore;
+        }
+
+        return { scores, fontTagsMap, uniqueTags: Array.from(uniqueTags).sort() };
+    }
+
+    function aggregateData(fontList, fontTagsMap, scores, uniqueTags) {
+        const data = {};
+        
+        ALLOWED_SUBSETS.forEach(sub => {
+            data[sub] = { tagsUsedCount: 0, tagBuckets: {} };
+            uniqueTags.forEach(tag => data[sub].tagBuckets[tag] = []);
+        });
+
+        fontList.forEach(font => {
+            const familyName = font.family;
+            const quality = scores[familyName] || 0;
+            if (quality < QUALITY_THRESHOLD) return;
+
+            const fontTags = fontTagsMap[familyName]; 
+            if (!fontTags) return; 
+
+            font.subsets.forEach(subset => {
+                if (!data[subset]) return; 
+                fontTags.forEach(tag => {
+                    if (data[subset].tagBuckets[tag]) {
+                        data[subset].tagBuckets[tag].push(familyName);
+                    }
+                });
+            });
+        });
+
+        Object.values(data).forEach(row => {
+            let used = 0;
+            uniqueTags.forEach(tag => {
+                if (row.tagBuckets[tag].length > 0) used++;
+            });
+            row.tagsUsedCount = used;
+        });
+
+        return data;
+    }
+
+function renderTable(data, uniqueTags) {
+        const table = document.getElementById('dashboard');
+        const thead = table.querySelector('thead');
+        const tbody = table.querySelector('tbody');
+
+        let headerHTML = `<tr>
+            <th>Language Subset</th>
+            <th>Tags populated</th>
+            <th>Coverage %</th>`;
+        
+        uniqueTags.forEach(tag => {
+            // UPDATED: Use the full tag name directly
+            const displayName = tag; 
+            headerHTML += `<th title="${tag}">${displayName}</th>`;
+        });
+        headerHTML += `</tr>`;
+        thead.innerHTML = headerHTML;
+
+        // Sort by Tags Populated Descending
+        const sortedSubsets = Object.entries(data).sort((a, b) => b[1].tagsUsedCount - a[1].tagsUsedCount);
+        const totalPossibleTags = uniqueTags.length;
+
+        sortedSubsets.forEach(([subset, rowData]) => {
+            const tr = document.createElement('tr');
+
+            // Apply Highlight Class if applicable
+            if (HIGHLIGHT_SUBSETS.includes(subset)) {
+                tr.classList.add('highlight-row');
+            }
+
+            const coveragePct = totalPossibleTags > 0 ? (rowData.tagsUsedCount / totalPossibleTags) : 0;
+            const coverageStr = (coveragePct * 100).toFixed(1) + '%';
+
+            tr.innerHTML = `
+                <td>${subset}</td>
+                <td style="font-weight:bold; text-align:right;">${rowData.tagsUsedCount}</td>
+                <td style="text-align:right; color:#5f6368;">${coverageStr}</td>
+            `;
+
+            uniqueTags.forEach(tag => {
+                const fontList = rowData.tagBuckets[tag];
+                const count = fontList.length;
+                const td = document.createElement('td');
+                
+                td.textContent = count;
+
+                if (count === 0) {
+                    td.className = 'cell-zero';
+                    td.textContent = "-"; 
+                } else if (count === 1) {
+                    td.className = 'cell-one clickable';
+                    td.onclick = () => openModal(subset, tag, fontList);
+                } else {
+                    td.className = 'cell-many clickable';
+                    td.onclick = () => openModal(subset, tag, fontList);
+                }
+                
+                tr.appendChild(td);
+            });
+
+            tbody.appendChild(tr);
+        });
+    }
+
+    // Modal Logic
+    const modal = document.getElementById('modal');
+    const modalTitle = document.getElementById('modal-title');
+    const fontListEl = document.getElementById('font-list');
+
+    function openModal(subset, tag, fonts) {
+        modalTitle.textContent = `${subset} — ${tag} (${fonts.length})`;
+        fontListEl.innerHTML = '';
+        fonts.sort((a, b) => a.localeCompare(b));
+        fonts.forEach(font => {
+            const li = document.createElement('li');
+            const a = document.createElement('a');
+            a.textContent = font;
+            a.href = `https://fonts.google.com/specimen/${font.replace(/ /g, '+')}`;
+            a.target = "_blank";
+            li.appendChild(a);
+            fontListEl.appendChild(li);
+        });
+        modal.classList.add('modal-active');
+    }
+
+    function closeModal() { modal.classList.remove('modal-active'); }
+    modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
+
+</script>
+</body>
+</html>
\ No newline at end of file
diff --git a/.ci/quality_outperforming.html b/.ci/quality_outperforming.html
new file mode 100644 (file)
index 0000000..08ae483
--- /dev/null
@@ -0,0 +1,426 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Top 50% Fonts Tag Distribution</title>
+    <style>
+        /* --- LAYOUT --- */
+        html, body {
+            height: 100%;
+            margin: 0;
+            overflow: hidden; 
+            background: #f8f9fa; 
+            color: #202124;
+            font-family: 'Google Sans', Roboto, sans-serif; 
+        }
+
+        body {
+            display: flex;
+            flex-direction: column;
+            padding: 20px;
+            box-sizing: border-box;
+        }
+
+        .header-section {
+            flex: 0 0 auto;
+            margin-bottom: 20px;
+        }
+
+        h1 { margin: 0 0 5px 0; }
+        p.subtitle { color: #5f6368; margin: 0 0 15px 0; font-size: 0.9rem; }
+
+        /* --- CARD & TABLE --- */
+        .card {
+            flex: 1;
+            background: white;
+            border-radius: 8px;
+            box-shadow: 0 1px 3px rgba(0,0,0,0.2);
+            display: flex;
+            flex-direction: column;
+            overflow: hidden;
+        }
+
+        .card-header {
+            padding: 15px 20px;
+            background: #f1f3f4;
+            border-bottom: 1px solid #ddd;
+            font-weight: 600;
+            color: #202124;
+            font-size: 1rem;
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+        }
+
+        .table-container {
+            flex: 1;
+            overflow: auto;
+        }
+
+        table { width: 100%; border-collapse: separate; border-spacing: 0; min-width: 800px; }
+        
+        th { 
+            background-color: #fff; 
+            position: sticky; top: 0; z-index: 10; 
+            font-size: 0.75rem; 
+            text-transform: uppercase; letter-spacing: 0.5px; 
+            padding: 12px 16px; border-bottom: 2px solid #eee; 
+            text-align: left; color: #5f6368;
+            cursor: pointer;
+            user-select: none;
+        }
+        
+        th:hover { background-color: #f1f3f4; color: #202124; }
+        
+        /* Sort indicators */
+        th::after { content: ' \2195'; color: #ccc; font-size: 0.8em; margin-left: 5px; }
+        th.asc::after { content: ' \2191'; color: #202124; }
+        th.desc::after { content: ' \2193'; color: #202124; }
+
+        td { 
+            padding: 10px 16px; 
+            border-bottom: 1px solid #f0f0f0; 
+            font-size: 0.9rem; 
+            vertical-align: middle;
+        }
+
+        td.num { text-align: right; font-variant-numeric: tabular-nums; font-weight: 500; }
+        td.tag { color: #1a73e8; font-weight: 500; }
+        .ratio-context { font-size: 0.8em; color: #888; margin-left: 6px; font-weight: normal; }
+
+        tr:hover td { background-color: #f8f9fa; }
+
+        /* Visual Bars */
+        .bar-container { display: flex; align-items: center; justify-content: flex-end; gap: 10px; }
+        .bar-bg { width: 100px; height: 6px; background: #eee; border-radius: 3px; }
+        .bar-fill { height: 100%; background: #1a73e8; border-radius: 3px; transition: width 0.3s; }
+
+        #status { padding: 10px; margin-bottom: 15px; border-radius: 4px; display: none; }
+        .loading { background-color: #e8f0fe; color: #1967d2; display: block !important; }
+        .error { background-color: #fce8e6; color: #c5221f; display: block !important; }
+        
+        /* Modal */
+        .modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; justify-content: center; align-items: center; }
+        .modal-active { display: flex; }
+        .modal-content { background: white; width: 90%; max-width: 500px; max-height: 80vh; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); display: flex; flex-direction: column; }
+        .modal-header { padding: 16px 20px; border-bottom: 1px solid #ddd; display: flex; justify-content: space-between; align-items: center; background: #f1f3f4; }
+        .modal-title { margin: 0; font-size: 1.1rem; color: #202124; }
+        .close-btn { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #5f6368; }
+        .modal-body { padding: 20px; overflow-y: auto; }
+        ul.font-list { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 8px; }
+        ul.font-list li a { text-decoration: none; color: #1a73e8; font-size: 0.9rem; display: block; padding: 4px 10px; background:#e8f0fe; border-radius: 12px; }
+        ul.font-list li a:hover { background-color: #d2e3fc; }
+    </style>
+</head>
+<body>
+
+    <div class="header-section">
+        <h1>Top 50% Fonts Tag Distribution</h1>
+        <p class="subtitle">Analyzing which tags are represented by the top 50% font families by usage.</p>
+    </div>
+
+    <div class="card">
+        <div class="card-header">
+            Tag Analysis
+            <span style="font-size:0.8rem; color:#5f6368; font-weight:normal;">Click headers to sort</span>
+        </div>
+        <div class="table-container">
+            <table id="main-table">
+                <thead>
+                    <tr>
+                        <th onclick="sortTable('tag')" id="th-tag">Tag Category</th>
+                        <th onclick="sortTable('count')" id="th-count" style="text-align:right">Top 50% Count</th>
+                        <th onclick="sortTable('ratio')" id="th-ratio" style="text-align:right">Ratio (vs Total)</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    </tbody>
+            </table>
+        </div>
+    </div>
+
+    <div id="modal" class="modal-overlay">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h2 id="modal-title" class="modal-title">Fonts List</h2>
+                <button class="close-btn" onclick="closeModal()">&times;</button>
+            </div>
+            <div class="modal-body">
+                <ul id="font-list" class="font-list"></ul>
+            </div>
+        </div>
+    </div>
+
+<script>
+    // --- Configuration ---
+    const METADATA_URL = "catalog_metadata.json";
+    const TAGS_URL = "https://raw.githubusercontent.com/google/fonts/refs/heads/main/tags/all/families.csv";
+    const USAGE_URL = "usage_data.csv"; 
+
+    // Global Data Storage
+    let TABLE_DATA = [];
+    if(!window.TAG_LOOKUP) window.TAG_LOOKUP = {};
+
+    // --- Main Execution ---
+    document.addEventListener("DOMContentLoaded", async () => {
+        const statusDiv = document.getElementById('status');
+        try {
+            const [metaRes, tagsRes, usageRes] = await Promise.all([ 
+                fetch(METADATA_URL), 
+                fetch(TAGS_URL),
+                fetch(USAGE_URL)
+            ]);
+
+            if (!metaRes.ok) throw new Error(`Metadata Error: ${metaRes.status}`);
+            if (!tagsRes.ok) throw new Error(`Tags CSV Error: ${tagsRes.status}`);
+            if (!usageRes.ok) throw new Error(`Usage CSV Error: ${usageRes.status}`);
+
+            const metadata = await metaRes.json();
+            const tagsText = await tagsRes.text();
+            const usageText = await usageRes.text();
+
+            const fontTagsMap = parseTags(tagsText);
+            const usageMap = parseUsageCSV(usageText);
+
+            // 1. Identify Top 50% Fonts
+            const top50Fonts = getTop50PercentFonts(metadata.familyMetadataList, usageMap);
+
+            // 2. Aggregation
+            const tagCountsTop50 = countTagsInSet(top50Fonts, fontTagsMap);
+            const tagCountsTotal = countTotalTags(metadata.familyMetadataList, fontTagsMap);
+
+            // 3. Merge Data for Table
+            TABLE_DATA = mergeData(tagCountsTop50, tagCountsTotal);
+
+            // 4. Initial Render (Sort by Count Descending)
+            sortTable('count'); 
+            statusDiv.style.display = 'none';
+
+        } catch (err) {
+            console.error(err);
+            statusDiv.innerHTML = `<strong>Error:</strong> ${err.message}<br><small>Note: usage_data.csv must be in the same folder.</small>`;
+            statusDiv.className = 'error';
+        }
+    });
+
+    // --- Data Processing ---
+
+    function parseCSVLine(line) {
+        let parts = [];
+        let current = '';
+        let inQuotes = false;
+        for (let i = 0; i < line.length; i++) {
+            let char = line[i];
+            if (char === '"') { inQuotes = !inQuotes; } 
+            else if (char === ',' && !inQuotes) { parts.push(current); current = ''; } 
+            else { current += char; }
+        }
+        parts.push(current);
+        return parts.map(p => p.trim().replace(/^"|"$/g, ''));
+    }
+
+    function parseTags(csvText) {
+        const lines = csvText.split('\n');
+        const fontTags = {}; 
+
+        for (let i = 1; i < lines.length; i++) {
+            const line = lines[i].trim();
+            if(!line) continue;
+            const parts = parseCSVLine(line);
+            if (parts.length < 3) continue;
+
+            const family = parts[0]; 
+            let rawTag = parts[2];
+            
+            if (!rawTag && parts[1] && parts[1].includes('/')) rawTag = parts[1];
+            if (!rawTag) continue;
+
+            if (rawTag.includes("Quality/")) continue; 
+
+            let cleanTag = rawTag.replace(/^\/+/g, '');
+            
+            if (!fontTags[family]) fontTags[family] = new Set();
+            fontTags[family].add(cleanTag);
+        }
+        return fontTags;
+    }
+
+    function parseUsageCSV(csvText) {
+        const lines = csvText.split('\n');
+        const usage = {}; 
+        for (let i = 0; i < lines.length; i++) {
+            const line = lines[i].trim();
+            if (!line) continue;
+            const parts = parseCSVLine(line);
+            if (parts.length < 2) continue;
+            const family = parts[0];
+            const views = parseInt(parts[1].replace(/,/g, ''));
+            if (!isNaN(views)) usage[family] = views;
+        }
+        return usage;
+    }
+
+    function getTop50PercentFonts(fontList, usageMap) {
+        let allFonts = fontList.map(f => ({
+            family: f.family,
+            views: usageMap[f.family] || 0
+        }));
+        allFonts.sort((a, b) => b.views - a.views);
+        const medianIndex = Math.floor(allFonts.length / 2);
+        return allFonts.slice(0, medianIndex); 
+    }
+
+    function countTagsInSet(fontList, fontTagsMap) {
+        const counts = {};
+        fontList.forEach(font => {
+            const tags = fontTagsMap[font.family];
+            if (tags) {
+                tags.forEach(tag => {
+                    if (!counts[tag]) counts[tag] = { tag: tag, count: 0, fonts: [] };
+                    counts[tag].count++;
+                    counts[tag].fonts.push(font.family);
+                });
+            }
+        });
+        return Object.values(counts);
+    }
+
+    function countTotalTags(fontList, fontTagsMap) {
+        const totals = {};
+        fontList.forEach(font => {
+            const tags = fontTagsMap[font.family];
+            if (tags) {
+                tags.forEach(tag => {
+                    totals[tag] = (totals[tag] || 0) + 1;
+                });
+            }
+        });
+        return totals;
+    }
+
+    function mergeData(top50Data, totalData) {
+        // top50Data is array of objects {tag, count, fonts[]}
+        // We map over it to add total and ratio
+        return top50Data.map(item => {
+            const total = totalData[item.tag] || item.count;
+            return {
+                ...item,
+                total: total,
+                ratio: total > 0 ? (item.count / total) : 0
+            };
+        });
+    }
+
+    // --- Rendering ---
+
+    function renderTable() {
+        const tbody = document.querySelector('#main-table tbody');
+        tbody.innerHTML = '';
+
+        // Find max count for bar chart scaling
+        const maxCount = Math.max(...TABLE_DATA.map(d => d.count), 1);
+
+        TABLE_DATA.forEach(row => {
+            const tr = document.createElement('tr');
+            
+            const pct = (row.count / maxCount) * 100;
+            const ratioPercent = (row.ratio * 100).toFixed(0);
+
+            tr.innerHTML = `
+                <td class="tag">${row.tag}</td>
+                
+                <td class="num" style="cursor:pointer" onclick="openModal('${row.tag}')">
+                    <div class="bar-container">
+                        <span>${row.count}</span>
+                        <div class="bar-bg">
+                            <div class="bar-fill" style="width:${pct}%"></div>
+                        </div>
+                    </div>
+                </td>
+
+                <td class="num">
+                    ${ratioPercent}%
+                    <span class="ratio-context">(${row.count}/${row.total})</span>
+                </td>
+            `;
+            tbody.appendChild(tr);
+            
+            // Populate global lookup for modal
+            window.TAG_LOOKUP[row.tag] = row.fonts;
+        });
+    }
+
+    // --- Sorting ---
+    let currentSort = { col: 'count', dir: 'desc' };
+
+    window.sortTable = function(col) {
+        // Toggle direction if clicking same column
+        if (currentSort.col === col) {
+            currentSort.dir = currentSort.dir === 'asc' ? 'desc' : 'asc';
+        } else {
+            currentSort.col = col;
+            currentSort.dir = 'desc'; // Default desc for new sorts
+        }
+
+        // Update Header Styles
+        document.querySelectorAll('th').forEach(th => th.className = '');
+        const activeHeader = document.getElementById(`th-${col}`);
+        if(activeHeader) activeHeader.classList.add(currentSort.dir);
+
+        // Sort Data
+        TABLE_DATA.sort((a, b) => {
+            let valA = a[col];
+            let valB = b[col];
+
+            // String sort for Tag
+            if (col === 'tag') {
+                return currentSort.dir === 'asc' 
+                    ? valA.localeCompare(valB) 
+                    : valB.localeCompare(valA);
+            }
+            // Numeric sort for Count/Ratio
+            return currentSort.dir === 'asc' ? valA - valB : valB - valA;
+        });
+
+        renderTable();
+    }
+
+    // --- Modal Logic ---
+    const modal = document.getElementById('modal');
+    const modalTitle = document.getElementById('modal-title');
+    const fontListEl = document.getElementById('font-list');
+
+    window.openModal = function(tag) {
+        const fonts = window.TAG_LOOKUP[tag];
+        if(!fonts) return;
+
+        modalTitle.textContent = `${tag} (Top 50%) — ${fonts.length} Fonts`;
+        fontListEl.innerHTML = '';
+        
+        fonts.sort((a, b) => a.localeCompare(b));
+        
+        fonts.forEach(font => {
+            const li = document.createElement('li');
+            const a = document.createElement('a');
+            a.textContent = font;
+            a.href = `https://fonts.google.com/specimen/${font.replace(/ /g, '+')}`;
+            a.target = "_blank";
+            li.appendChild(a);
+            fontListEl.appendChild(li);
+        });
+
+        modal.classList.add('modal-active');
+    }
+
+    window.closeModal = function() {
+        modal.classList.remove('modal-active');
+    }
+    
+    modal.addEventListener('click', (e) => {
+        if (e.target === modal) closeModal();
+    });
+
+</script>
+</body>
+</html>
\ No newline at end of file
diff --git a/.ci/quality_overview.html b/.ci/quality_overview.html
new file mode 100644 (file)
index 0000000..c6d5308
--- /dev/null
@@ -0,0 +1,295 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Google Fonts Quality Dashboard</title>
+    <style>
+        html, body {
+            height: 100%;
+            margin: 0;
+            overflow: hidden; /* Prevent full page scroll */
+            background: #f8f9fa; 
+            color: #202124;
+            font-family: 'Google Sans', Roboto, sans-serif; 
+        }
+
+        body {
+            display: flex;
+            flex-direction: column;
+            padding: 20px;
+            box-sizing: border-box;
+        }
+        h1 { margin-bottom: 5px; }
+        p.subtitle { color: #5f6368; margin-top: 0; margin-bottom: 20px; font-size: 0.9rem; }
+        
+        /* Table Styles */
+        .table-container { overflow-x: auto; box-shadow: 0 1px 3px rgba(0,0,0,0.2); background: white; border-radius: 8px; }
+        table { border-collapse: collapse; width: 100%; min-width: 800px; }
+        th, td { text-align: left; padding: 12px 16px; border-bottom: 1px solid #ddd; }
+        th { background-color: #f1f3f4; position: sticky; top: 0; font-weight: 600; color: #5f6368; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.5px; }
+        
+        /* Highlighting Logic */
+        tr:hover { background-color: #f1f3f4 !important; } 
+        tr.highlight-row { background-color: #fff8e1; } 
+        /*tr.highlight-row td:first-child { font-weight: bold; color: #b06000; }
+
+        /* Numeric columns */
+        .num { text-align: right; font-variant-numeric: tabular-nums; }
+        
+        /* Interactive Cells */
+        .clickable { cursor: pointer; color: #1a73e8; text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 3px; }
+        .clickable:hover { background-color: #d2e3fc; color: #174ea6; }
+
+        /* Conditional Formatting */
+        .good-ratio { color: #137333; font-weight: bold; background-color: #e6f4ea; }
+        .zero-score { color: #aaa; pointer-events: none; } 
+        
+        /* Status Bar */
+        #status { padding: 10px; margin-bottom: 15px; border-radius: 4px; display: none; }
+        .loading { background-color: #e8f0fe; color: #1967d2; display: block !important; }
+        .error { background-color: #fce8e6; color: #c5221f; display: block !important; }
+
+        /* Modal Styles */
+        .modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; justify-content: center; align-items: center; }
+        .modal-active { display: flex; }
+        .modal-content { background: white; width: 90%; max-width: 600px; max-height: 80vh; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); display: flex; flex-direction: column; }
+        .modal-header { padding: 16px 20px; border-bottom: 1px solid #ddd; display: flex; justify-content: space-between; align-items: center; background: #f1f3f4; border-radius: 8px 8px 0 0; }
+        .modal-title { margin: 0; font-size: 1.1rem; color: #202124; }
+        .close-btn { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #5f6368; }
+        .modal-body { padding: 20px; overflow-y: auto; }
+        
+        ul.font-list { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; }
+        ul.font-list li a { text-decoration: none; color: #1a73e8; font-size: 0.95rem; display: block; padding: 4px 8px; border-radius: 4px; }
+        ul.font-list li a:hover { background-color: #e8f0fe; }
+    </style>
+</head>
+<body>
+
+    <h1>Font Quality by Language Subset</h1>
+    <p class="subtitle">Live data from Google Fonts Metadata & GitHub Tags. Sorted by count of fonts > 70.</p>
+
+    <div class="table-container">
+        <table id="dashboard">
+            <thead>
+                <tr>
+                    <th>Language Subset</th>
+                    <th class="num">Total Fonts</th>
+                    <th class="num">Score &lt; 50</th>
+                    <th class="num">50–60</th>
+                    <th class="num">60–70</th>
+                    <th class="num">70–80</th>
+                    <th class="num">&gt; 80</th>
+                    <th class="num" style="background: #e8f0fe;">Total &gt; 70</th>
+                    <th class="num">Ratio (&gt;70)</th>
+                </tr>
+            </thead>
+            <tbody>
+                </tbody>
+        </table>
+    </div>
+
+    <div id="modal" class="modal-overlay">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h2 id="modal-title" class="modal-title">Fonts List</h2>
+                <button class="close-btn" onclick="closeModal()">&times;</button>
+            </div>
+            <div class="modal-body">
+                <ul id="font-list" class="font-list"></ul>
+            </div>
+        </div>
+    </div>
+
+<script>
+    // --- Configuration ---
+    const METADATA_URL = "catalog_metadata.json"; 
+    const TAGS_URL = "https://raw.githubusercontent.com/google/fonts/refs/heads/main/tags/all/families.csv";
+    
+    const WEIGHTS = { "Concept": 0.4, "Drawing": 0.3, "Spacing": 0.2, "Wordspace": 0.1 };
+
+    const ALLOWED_SUBSETS = [
+        "latin", "devanagari", "japanese", "korean", "arabic", "thai", 
+        "bengali", "chinese-simplified", "cyrillic", "greek", "hebrew", 
+        "tamil", "telugu", "gujarati", "kannada", "malayalam", "gurmukhi", 
+        "oriya", "myanmar", "armenian", "sinhala", "georgian", "chinese-traditional"
+    ];
+
+    const HIGHLIGHT_SUBSETS = [
+        "latin", "cyrillic", "greek", "devanagari", "hebrew", "arabic", 
+        "korean", "japanese", "thai", "bengali", "chinese-simplified"
+    ];
+
+    // --- Main Execution ---
+    document.addEventListener("DOMContentLoaded", async () => {
+        const statusDiv = document.getElementById('status');
+        try {
+            const [metaRes, tagsRes] = await Promise.all([ fetch(METADATA_URL), fetch(TAGS_URL) ]);
+
+            if (!metaRes.ok) throw new Error(`Metadata Error: ${metaRes.status}`);
+            if (!tagsRes.ok) throw new Error(`Tags CSV Error: ${tagsRes.status}`);
+
+            const metadata = await metaRes.json();
+            const tagsText = await tagsRes.text();
+
+            const scores = parseTagsAndCalculateScores(tagsText);
+            const stats = aggregateStats(metadata.familyMetadataList, scores);
+            
+            renderTable(stats);
+            statusDiv.style.display = 'none';
+
+        } catch (err) {
+            console.error(err);
+            statusDiv.textContent = `Error loading data: ${err.message}.`;
+            statusDiv.className = 'error';
+        }
+    });
+
+    // --- Modal Logic ---
+    const modal = document.getElementById('modal');
+    const modalTitle = document.getElementById('modal-title');
+    const fontListEl = document.getElementById('font-list');
+
+    function openModal(subset, category, fonts) {
+        modalTitle.textContent = `${subset} — ${category} (${fonts.length})`;
+        fontListEl.innerHTML = '';
+        fonts.sort((a, b) => a.localeCompare(b));
+        fonts.forEach(font => {
+            const li = document.createElement('li');
+            const a = document.createElement('a');
+            a.textContent = font;
+            a.href = `https://fonts.google.com/specimen/${font.replace(/ /g, '+')}`;
+            a.target = "_blank";
+            li.appendChild(a);
+            fontListEl.appendChild(li);
+        });
+        modal.classList.add('modal-active');
+    }
+
+    function closeModal() { modal.classList.remove('modal-active'); }
+    modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
+
+    // --- Data Logic ---
+    function parseTagsAndCalculateScores(csvText) {
+        const lines = csvText.split('\n');
+        const rawValues = {};
+        const familyScores = {};
+
+        for (let i = 1; i < lines.length; i++) {
+            const row = lines[i].split(',');
+            if (row.length < 4) continue;
+
+            const family = row[0].trim();
+            const tag = row[2].trim(); 
+            const weight = parseFloat(row[3]);
+
+            if (tag.includes('Quality/')) {
+                const parts = tag.split('Quality/');
+                const type = parts[1];
+                if (type && !isNaN(weight)) {
+                    if (!rawValues[family]) rawValues[family] = {};
+                    rawValues[family][type] = weight;
+                }
+            }
+        }
+
+        for (const [family, categories] of Object.entries(rawValues)) {
+            let totalScore = 0;
+            totalScore += (categories['Concept'] || 0) * WEIGHTS['Concept'];
+            totalScore += (categories['Drawing'] || 0) * WEIGHTS['Drawing'];
+            totalScore += (categories['Spacing'] || 0) * WEIGHTS['Spacing'];
+            totalScore += (categories['Wordspace'] || 0) * WEIGHTS['Wordspace'];
+            familyScores[family] = totalScore;
+        }
+        return familyScores;
+    }
+
+    function aggregateStats(fontList, scores) {
+        const subsetStats = {};
+        ALLOWED_SUBSETS.forEach(sub => {
+            subsetStats[sub] = {
+                total: [], below_50: [], score_50_60: [], score_60_70: [], 
+                score_70_80: [], above_80: [], above_70_total: [] 
+            };
+        });
+
+        fontList.forEach(font => {
+            const score = scores[font.family] || 0; 
+            const name = font.family;
+            font.subsets.forEach(subset => {
+                if (!subsetStats[subset]) return;
+                const s = subsetStats[subset];
+                s.total.push(name);
+                if (score < 50) s.below_50.push(name);
+                else if (score < 60) s.score_50_60.push(name);
+                else if (score < 70) s.score_60_70.push(name);
+                else if (score < 80) s.score_70_80.push(name);
+                else s.above_80.push(name);
+                if (score >= 70) s.above_70_total.push(name);
+            });
+        });
+        return subsetStats;
+    }
+
+    function renderTable(stats) {
+        const tbody = document.querySelector('#dashboard tbody');
+        let sortedSubsets = Object.entries(stats);
+        
+        // --- NEW SORTING LOGIC ---
+        // Sort strictly by the length of "above_70_total" array (descending)
+        sortedSubsets.sort((a, b) => {
+            return b[1].above_70_total.length - a[1].above_70_total.length;
+        });
+
+        sortedSubsets.forEach(([name, data]) => {
+            const tr = document.createElement('tr');
+            
+            // Apply highlight class if language is in the priority list
+            if (HIGHLIGHT_SUBSETS.includes(name)) {
+                tr.classList.add('highlight-row');
+            }
+
+            const totalCount = data.total.length;
+            const highQualityCount = data.above_70_total.length;
+            const ratio = totalCount > 0 ? (highQualityCount / totalCount) : 0;
+            const ratioPercent = (ratio * 100).toFixed(1) + '%';
+            const ratioClass = ratio > 0.5 ? 'good-ratio' : '';
+
+            const createCell = (list, label) => {
+                const count = list.length;
+                const isZero = count === 0;
+                const td = document.createElement('td');
+                td.className = `num ${isZero ? 'zero-score' : 'clickable'}`;
+                td.textContent = count;
+                if (!isZero) td.onclick = () => openModal(name, label, list);
+                return td;
+            };
+
+            const tdName = document.createElement('td');
+            tdName.textContent = name;
+            tr.appendChild(tdName);
+
+            tr.appendChild(createCell(data.total, "Total Fonts"));
+            tr.appendChild(createCell(data.below_50, "Score < 50"));
+            tr.appendChild(createCell(data.score_50_60, "Score 50–60"));
+            tr.appendChild(createCell(data.score_60_70, "Score 60–70"));
+            tr.appendChild(createCell(data.score_70_80, "Score 70–80"));
+            tr.appendChild(createCell(data.above_80, "Score > 80"));
+            
+            // Highlight the column we are sorting by
+            const tdAbove70 = createCell(data.above_70_total, "Total Score > 70");
+            tdAbove70.style.background = "#e8f0fe"; // subtle blue highlight
+            tr.appendChild(tdAbove70);
+
+            const tdRatio = document.createElement('td');
+            tdRatio.className = `num ${ratioClass}`;
+            tdRatio.textContent = ratioPercent;
+            tr.appendChild(tdRatio);
+
+            tbody.appendChild(tr);
+        });
+    }
+</script>
+
+</body>
+</html>
\ No newline at end of file
diff --git a/.ci/quality_perception.html b/.ci/quality_perception.html
new file mode 100644 (file)
index 0000000..e875116
--- /dev/null
@@ -0,0 +1,549 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Tag Perception</title>
+    <style>
+        /* --- LAYOUT --- */
+        html, body {
+            height: 100%;
+            margin: 0;
+            overflow: hidden; 
+            background: #f8f9fa; 
+            color: #202124;
+            font-family: 'Google Sans', Roboto, sans-serif; 
+        }
+
+        body {
+            display: flex;
+            flex-direction: column;
+            padding: 20px;
+            box-sizing: border-box;
+        }
+
+        .header-section {
+            flex: 0 0 auto;
+            margin-bottom: 15px;
+        }
+
+        h1 { margin: 0 0 5px 0; }
+        p.subtitle { color: #5f6368; margin: 0 0 15px 0; font-size: 0.9rem; }
+
+        /* --- CHART SECTION --- */
+        .chart-section {
+            flex: 0 0 auto;
+            background: white;
+            border-radius: 8px;
+            box-shadow: 0 1px 3px rgba(0,0,0,0.2);
+            margin-bottom: 20px;
+            padding: 15px 20px;
+            display: flex;
+            flex-direction: column;
+        }
+
+        .chart-title {
+            font-size: 0.85rem;
+            font-weight: 600;
+            color: #5f6368;
+            margin-bottom: 10px;
+            text-transform: uppercase;
+        }
+
+        /* INCREASED HEIGHT & PADDING HERE */
+        .chart-container {
+            height: 180px; /* Taller to fit labels */
+            display: flex;
+            align-items: flex-end;
+            gap: 4px;
+            overflow-x: auto; 
+            padding-bottom: 120px; /* More space for rotated text */
+            scrollbar-width: thin;
+            scrollbar-color: #ccc transparent;
+
+            background-image: linear-gradient(
+                to bottom,
+                transparent 89px,
+                #dadce0 89px,
+                #dadce0 90px,
+                transparent 90px
+            );
+            background-attachment: local;
+            background-attachment: scroll;
+            background-repeat: no-repeat
+        }
+        
+        .chart-container::-webkit-scrollbar { height: 6px; }
+        .chart-container::-webkit-scrollbar-thumb { background-color: #ccc; border-radius: 3px; }
+
+        .chart-bar-group {
+            display: flex;
+            flex-direction: column;
+            align-items: center;
+            min-width: 30px; 
+            height: 100%;
+            justify-content: flex-end;
+            position: relative;
+        }
+
+        .chart-bar {
+            width: 18px;
+            background-color: #34a853;
+            border-radius: 3px 3px 0 0;
+            transition: height 0.3s ease;
+            position: relative;
+        }
+        
+        .chart-bar:hover { background-color: #2d9147; }
+        
+        .chart-bar:hover::after {
+            content: attr(data-val);
+            position: absolute;
+            bottom: 100%;
+            left: 50%;
+            transform: translateX(-50%);
+            background: rgba(0,0,0,0.8);
+            color: white;
+            padding: 4px 6px;
+            border-radius: 4px;
+            font-size: 0.75rem;
+            white-space: nowrap;
+            margin-bottom: 4px;
+            z-index: 10;
+        }
+
+        /* ADJUSTED LABEL POSITIONING */
+        .chart-label {
+            margin-top: 10px; /* Shift down */
+            font-size: 9px;
+            color: #5f6368;
+            white-space: nowrap;
+            overflow: hidden;
+            text-overflow: ellipsis;
+            width: 120px; /* Wider */
+            text-align: left;
+            
+            /* Rotate 45deg (down-right) from the top-left corner */
+            transform: rotate(45deg);
+            transform-origin: left top; 
+            
+            position: absolute;
+            top: 100%;
+            left: 50%; /* Start at center of bar */
+            margin-left: -2px; /* Slight centering adjustment */
+        }
+        
+        /* --- TABLE SECTION --- */
+        .table-container { 
+            flex: 1; 
+            overflow: auto; 
+            background: white; 
+            border-radius: 8px; 
+            box-shadow: 0 1px 3px rgba(0,0,0,0.2); 
+            position: relative;
+        }
+
+        table { border-collapse: separate; border-spacing: 0; width: 100%; min-width: 1000px; }
+        
+        th { 
+            background-color: #f1f3f4; 
+            position: sticky; top: 0; z-index: 10; 
+            font-weight: 600; color: #5f6368; font-size: 0.75rem; 
+            text-transform: uppercase; letter-spacing: 0.5px; 
+            padding: 12px 16px; border-bottom: 1px solid #ddd; 
+            text-align: left; 
+            cursor: pointer;
+        }
+        
+        th:hover { background-color: #e8eaed; color: #202124; }
+
+        td { font-size: 0.9rem; padding: 12px 16px; border-bottom: 1px solid #eee; vertical-align: middle; }
+        td.num { text-align: right; font-variant-numeric: tabular-nums; }
+        td.tag-name { font-weight: 500; color: #1a73e8; }
+        td.rank { color: #5f6368; font-weight: bold; }
+
+        .bar-container { display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
+        .bar { height: 6px; border-radius: 3px; min-width: 2px; }
+        .bar-high { background-color: #34a853; } 
+        .bar-mid  { background-color: #fbbc04; } 
+        .bar-low  { background-color: #ea4335; } 
+        .count-label { font-size: 0.85rem; color: #202124; min-width: 30px; text-align: right; }
+
+        #status { padding: 10px; margin-bottom: 15px; border-radius: 4px; display: none; }
+        .loading { background-color: #e8f0fe; color: #1967d2; display: block !important; }
+        .error { background-color: #fce8e6; color: #c5221f; display: block !important; }
+
+        .modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; justify-content: center; align-items: center; }
+        .modal-active { display: flex; }
+        .modal-content { background: white; width: 90%; max-width: 600px; max-height: 80vh; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); display: flex; flex-direction: column; }
+        .modal-header { padding: 16px 20px; border-bottom: 1px solid #ddd; display: flex; justify-content: space-between; align-items: center; background: #f1f3f4; }
+        .modal-title { margin: 0; font-size: 1.1rem; color: #202124; }
+        .close-btn { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #5f6368; }
+        .modal-body { padding: 20px; overflow-y: auto; }
+        ul.font-list { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; }
+        ul.font-list li a { text-decoration: none; color: #1a73e8; font-size: 0.95rem; display: block; padding: 4px 8px; border-radius: 4px; }
+        ul.font-list li a:hover { background-color: #e8f0fe; }
+
+    </style>
+</head>
+<body>
+
+    <div class="header-section">
+        <h1>Tag Perception</h1>
+        <p class="subtitle">Ranked by Usage, sorted by Ratio of good fonts to total fonts.</p>
+    </div>
+
+    <div class="chart-section">
+        <div class="chart-title">High Quality Ratio (>70) by Tag</div>
+        <div id="ratio-chart" class="chart-container">
+            </div>
+    </div>
+
+    <div class="table-container">
+        <table id="dashboard">
+            <thead>
+                <tr>
+                    <th onclick="sortTable('tag')">Tag / Category</th>
+                    <th onclick="sortTable('viewRank')" class="num" title="Fixed Rank by Total Views">Usage Rank</th>
+                    <th onclick="sortTable('views')" class="num">Total Monthly Views</th>
+                    <th onclick="sortTable('high')" class="num" title="Score >= 70">Score &ge; 70</th>
+                    <th onclick="sortTable('mid')" class="num" title="50 <= Score < 70">Score 50–70</th>
+                    <th onclick="sortTable('low')" class="num" title="Score < 50">Score &lt; 50</th>
+                    <th onclick="sortTable('ratio')" class="num">Ratio > 70</th>
+                </tr>
+            </thead>
+            <tbody>
+                </tbody>
+        </table>
+    </div>
+
+    <div id="modal" class="modal-overlay">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h2 id="modal-title" class="modal-title">Fonts List</h2>
+                <button class="close-btn" onclick="closeModal()">&times;</button>
+            </div>
+            <div class="modal-body">
+                <ul id="font-list" class="font-list"></ul>
+            </div>
+        </div>
+    </div>
+
+<script>
+    // --- Configuration ---
+    const METADATA_URL = "catalog_metadata.json";
+    const TAGS_URL = "https://raw.githubusercontent.com/google/fonts/refs/heads/main/tags/all/families.csv";
+    const USAGE_URL = "usage_data.csv"; 
+
+    const WEIGHTS = { "Concept": 0.4, "Drawing": 0.3, "Spacing": 0.2, "Wordspace": 0.1 };
+
+    let TABLE_DATA = [];
+
+    // --- Main Execution ---
+    document.addEventListener("DOMContentLoaded", async () => {
+        const statusDiv = document.getElementById('status');
+        try {
+            const [metaRes, tagsRes, usageRes] = await Promise.all([ 
+                fetch(METADATA_URL), 
+                fetch(TAGS_URL),
+                fetch(USAGE_URL)
+            ]);
+
+            if (!metaRes.ok) throw new Error(`Metadata Error: ${metaRes.status}`);
+            if (!tagsRes.ok) throw new Error(`Tags CSV Error: ${tagsRes.status}`);
+            if (!usageRes.ok) throw new Error(`Usage CSV Error: ${usageRes.status}`);
+
+            const metadata = await metaRes.json();
+            const tagsText = await tagsRes.text();
+            const usageText = await usageRes.text();
+
+            const { scores, fontTags } = parseTagsData(tagsText);
+            const usageMap = parseUsageCSV(usageText);
+
+            TABLE_DATA = aggregateData(metadata.familyMetadataList, scores, fontTags, usageMap);
+            
+            renderTable(TABLE_DATA);
+            statusDiv.style.display = 'none';
+
+        } catch (err) {
+            console.error(err);
+            statusDiv.innerHTML = `<strong>Error:</strong> ${err.message}<br><small>Note: usage_data.csv must be in the same folder.</small>`;
+            statusDiv.className = 'error';
+        }
+    });
+
+    // --- Parsing Logic ---
+
+    function parseCSVLine(line) {
+        let parts = [];
+        let current = '';
+        let inQuotes = false;
+        for (let i = 0; i < line.length; i++) {
+            let char = line[i];
+            if (char === '"') { inQuotes = !inQuotes; } 
+            else if (char === ',' && !inQuotes) { parts.push(current); current = ''; } 
+            else { current += char; }
+        }
+        parts.push(current);
+        return parts.map(p => p.trim().replace(/^"|"$/g, ''));
+    }
+
+    function parseTagsData(csvText) {
+        const lines = csvText.split('\n');
+        const rawQualityValues = {}; 
+        const fontTags = {}; 
+
+        for (let i = 1; i < lines.length; i++) {
+            const line = lines[i].trim();
+            if(!line) continue;
+            
+            const parts = parseCSVLine(line);
+            if (parts.length < 3) continue;
+
+            const family = parts[0]; 
+            let rawTag = parts[2];
+            let weightStr = parts[3];
+            
+            if (!rawTag && parts[1] && parts[1].includes('/')) {
+                rawTag = parts[1];
+                weightStr = parts[2];
+            }
+
+            if (!rawTag) continue;
+
+            if (rawTag.includes("Quality/")) {
+                const type = rawTag.split("Quality/")[1];
+                const weight = parseFloat(weightStr || 0);
+                if (type && !isNaN(weight)) {
+                    if (!rawQualityValues[family]) rawQualityValues[family] = {};
+                    rawQualityValues[family][type] = weight;
+                }
+            } else {
+                // Show full tag (remove only leading slash)
+                let cleanTag = rawTag.replace(/^\/+/g, '');
+                
+                if (!fontTags[family]) fontTags[family] = new Set();
+                fontTags[family].add(cleanTag);
+            }
+        }
+
+        const scores = {};
+        for (const [family, cats] of Object.entries(rawQualityValues)) {
+            let s = 0;
+            s += (cats['Concept'] || 0) * WEIGHTS['Concept'];
+            s += (cats['Drawing'] || 0) * WEIGHTS['Drawing'];
+            s += (cats['Spacing'] || 0) * WEIGHTS['Spacing'];
+            s += (cats['Wordspace'] || 0) * WEIGHTS['Wordspace'];
+            scores[family] = s;
+        }
+        
+        return { scores, fontTags };
+    }
+
+    function parseUsageCSV(csvText) {
+        const lines = csvText.split('\n');
+        const usage = {}; 
+        for (let i = 0; i < lines.length; i++) {
+            const line = lines[i].trim();
+            if (!line) continue;
+            const parts = parseCSVLine(line);
+            if (parts.length < 2) continue;
+            const family = parts[0];
+            const views = parseInt(parts[1].replace(/,/g, ''));
+            if (!isNaN(views)) usage[family] = views;
+        }
+        return usage;
+    }
+
+    // --- Aggregation Logic ---
+
+    function aggregateData(fontList, scores, fontTagsMap, usageMap) {
+        const tagStats = {}; 
+
+        fontList.forEach(font => {
+            const family = font.family;
+            const views = usageMap[family] || 0;
+            const score = scores[family] || 0;
+            const tags = fontTagsMap[family];
+
+            if (tags) {
+                tags.forEach(tag => {
+                    if (!tagStats[tag]) {
+                        tagStats[tag] = { 
+                            tag: tag, 
+                            views: 0, 
+                            high: 0, mid: 0, low: 0,
+                            fontsHigh: [], fontsMid: [], fontsLow: []
+                        };
+                    }
+
+                    tagStats[tag].views += views;
+
+                    if (score >= 70) {
+                        tagStats[tag].high++;
+                        tagStats[tag].fontsHigh.push(family);
+                    } else if (score >= 50) {
+                        tagStats[tag].mid++;
+                        tagStats[tag].fontsMid.push(family);
+                    } else {
+                        tagStats[tag].low++;
+                        tagStats[tag].fontsLow.push(family);
+                    }
+                });
+            }
+        });
+
+        const result = Object.values(tagStats).map(t => {
+            const total = t.high + t.mid + t.low;
+            t.ratio = total > 0 ? (t.high / total) : 0;
+            return t;
+        });
+
+        // 1. Assign Usage Rank
+        result.sort((a, b) => b.views - a.views);
+        result.forEach((item, index) => {
+            item.viewRank = index + 1;
+        });
+        
+        // 2. Initial Sort by Ratio Descending
+        result.sort((a, b) => b.ratio - a.ratio);
+
+        return result;
+    }
+
+    // --- Rendering ---
+
+    function renderTable(data) {
+        const tbody = document.querySelector('#dashboard tbody');
+        tbody.innerHTML = '';
+
+        renderChart(data);
+
+        data.forEach(row => {
+            const tr = document.createElement('tr');
+            const totalFonts = row.high + row.mid + row.low;
+            const ratioPercent = (row.ratio * 100).toFixed(1) + '%';
+            
+            const createBarCell = (count, type, fontList) => {
+                const percent = totalFonts > 0 ? (count / totalFonts) * 100 : 0;
+                const width = Math.max(2, percent * 0.8); 
+                
+                let barClass = type === 'high' ? 'bar-high' : type === 'mid' ? 'bar-mid' : 'bar-low';
+                let cursor = count > 0 ? 'pointer' : 'default';
+                let onclick = count > 0 ? `onclick="openModal('${row.tag}', '${type}', this)"` : '';
+
+                return `
+                    <td class="num" style="cursor:${cursor}" ${onclick}>
+                        <div class="bar-container">
+                            <div class="count-label">${count}</div>
+                            <div class="bar ${barClass}" style="width: ${width}px; opacity: ${count ? 1 : 0.1}"></div>
+                        </div>
+                    </td>
+                `;
+            };
+
+            tr.innerHTML = `
+                <td class="tag-name">${row.tag}</td>
+                <td class="num rank">#${row.viewRank}</td>
+                <td class="num"><strong>${row.views.toLocaleString()}</strong></td>
+                ${createBarCell(row.high, 'high', row.fontsHigh)}
+                ${createBarCell(row.mid, 'mid', row.fontsMid)}
+                ${createBarCell(row.low, 'low', row.fontsLow)}
+                <td class="num" style="font-weight:bold;">${ratioPercent}</td>
+            `;
+            tbody.appendChild(tr);
+        });
+    }
+
+    function renderChart(data) {
+        const container = document.getElementById('ratio-chart');
+        container.innerHTML = '';
+
+        data.forEach(row => {
+            const pct = (row.ratio * 100).toFixed(1);
+            const height = Math.max(2, row.ratio * 100); 
+
+            const barGroup = document.createElement('div');
+            barGroup.className = 'chart-bar-group';
+            
+            barGroup.innerHTML = `
+                <div class="chart-bar" style="height:${height}%" data-val="${pct}%"></div>
+                <div class="chart-label">${row.tag}</div>
+            `;
+            container.appendChild(barGroup);
+        });
+    }
+
+    // --- Modal Logic ---
+    const modal = document.getElementById('modal');
+    const modalTitle = document.getElementById('modal-title');
+    const fontListEl = document.getElementById('font-list');
+
+    window.openModal = function(tag, type, el) {
+        const rowData = TABLE_DATA.find(r => r.tag === tag);
+        if(!rowData) return;
+
+        let fonts = [];
+        let label = "";
+
+        if(type === 'high') { fonts = rowData.fontsHigh; label = "Score >= 70"; }
+        else if(type === 'mid') { fonts = rowData.fontsMid; label = "Score 50-70"; }
+        else { fonts = rowData.fontsLow; label = "Score < 50"; }
+
+        if(fonts.length === 0) return;
+
+        modalTitle.textContent = `${tag} — ${label} (${fonts.length})`;
+        fontListEl.innerHTML = '';
+        
+        fonts.sort((a, b) => a.localeCompare(b));
+        
+        fonts.forEach(font => {
+            const li = document.createElement('li');
+            const a = document.createElement('a');
+            a.textContent = font;
+            a.href = `https://fonts.google.com/specimen/${font.replace(/ /g, '+')}`;
+            a.target = "_blank";
+            li.appendChild(a);
+            fontListEl.appendChild(li);
+        });
+
+        modal.classList.add('modal-active');
+    }
+
+    window.closeModal = function() {
+        modal.classList.remove('modal-active');
+    }
+    
+    modal.addEventListener('click', (e) => {
+        if (e.target === modal) closeModal();
+    });
+
+    // --- Sorting ---
+    let currentSort = { col: 'ratio', dir: 'desc' };
+
+    window.sortTable = function(col) {
+        if (currentSort.col === col) {
+            currentSort.dir = currentSort.dir === 'asc' ? 'desc' : 'asc';
+        } else {
+            currentSort.col = col;
+            currentSort.dir = 'desc'; 
+        }
+
+        TABLE_DATA.sort((a, b) => {
+            let valA = a[col];
+            let valB = b[col];
+
+            if (col === 'tag') {
+                return currentSort.dir === 'asc' 
+                    ? valA.localeCompare(valB) 
+                    : valB.localeCompare(valA);
+            }
+            return currentSort.dir === 'asc' ? valA - valB : valB - valA;
+        });
+
+        renderTable(TABLE_DATA);
+    }
+
+</script>
+</body>
+</html>
\ No newline at end of file
diff --git a/.ci/quality_uplevel_analysis.html b/.ci/quality_uplevel_analysis.html
new file mode 100644 (file)
index 0000000..ee9f233
--- /dev/null
@@ -0,0 +1,464 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Multi-Threshold Tag Coverage Dashboard</title>
+    <style>
+        /* --- GLOBAL LAYOUT --- */
+        html, body {
+            height: 100%;
+            margin: 0;
+            overflow: hidden; 
+            background: #f8f9fa; 
+            color: #202124;
+            font-family: 'Google Sans', Roboto, sans-serif; 
+        }
+
+        body {
+            display: flex;
+            flex-direction: column;
+            padding: 20px;
+            box-sizing: border-box;
+        }
+
+        .header-section {
+            flex: 0 0 auto;
+            margin-bottom: 15px;
+        }
+
+        h1 { margin: 0 0 5px 0; }
+        p.subtitle { color: #5f6368; margin: 0 0 15px 0; font-size: 0.9rem; }
+
+        /* The scrolling container */
+        .table-container { 
+            flex: 1; 
+            overflow: auto; 
+            background: white; 
+            border-radius: 8px; 
+            box-shadow: 0 1px 3px rgba(0,0,0,0.2); 
+            position: relative;
+        }
+
+        /* --- TABLE RESET --- */
+        table { 
+            border-collapse: separate; 
+            border-spacing: 0; 
+            width: 100%; 
+            min-width: 1400px; 
+        }
+
+        /* --- STANDARD CELLS --- */
+        th, td {
+            padding: 8px;
+            font-size: 0.85rem;
+            white-space: nowrap;
+            box-sizing: border-box; 
+        }
+
+        /* Standard Header */
+        th { 
+            background-color: #f1f3f4; 
+            position: sticky; top: 0; z-index: 10; 
+            font-weight: 600; color: #5f6368; font-size: 0.75rem; 
+            text-transform: uppercase; letter-spacing: 0.5px; 
+            border-bottom: 1px solid #ddd; 
+            text-align: left; 
+        }
+
+        /* Standard Data Cell */
+        td { 
+            border-bottom: 1px solid #eee; 
+            text-align: right; 
+            border-right: 1px solid #f0f0f0; 
+        }
+
+        /* --- FROZEN COLUMNS LOGIC (Only 1 & 2) --- */
+        
+        th:nth-child(1), td:nth-child(1),
+        th:nth-child(2), td:nth-child(2) {
+            position: sticky;
+            z-index: 12; 
+            background-color: #fff;
+        }
+        
+        th:nth-child(1), th:nth-child(2) {
+            z-index: 14;
+            background-color: #f1f3f4;
+        }
+
+        /* Col 1: Language */
+        th:nth-child(1), td:nth-child(1) {
+            left: 0;
+            width: 160px; min-width: 160px; max-width: 160px;
+            border-right: 1px solid #e0e0e0;
+        }
+
+        /* Col 2: Threshold */
+        th:nth-child(2), td:nth-child(2) {
+            left: 160px;
+            width: 90px; min-width: 90px; max-width: 90px;
+            border-right: 2px solid #ccc; /* Heavy freeze line */
+        }
+
+        /* Col 3 & 4: Unfrozen */
+        th:nth-child(3), td:nth-child(3) {
+            width: 110px; min-width: 110px; 
+            text-align: right;
+            border-right: 1px solid #f0f0f0;
+        }
+        th:nth-child(4), td:nth-child(4) {
+            width: 100px; min-width: 100px; 
+            text-align: right;
+            border-right: 1px solid #f0f0f0;
+        }
+
+        /* --- ROW & DATA STYLING --- */
+        
+        .row-80 td:first-child { border-top: 2px solid #e0e0e0; font-weight: bold; color: #202124; } 
+        .row-70 td:first-child { padding-left: 20px; color: #5f6368; font-size: 0.8rem; }
+        .row-60 td:first-child { padding-left: 20px; color: #5f6368; font-size: 0.8rem; border-bottom: 1px solid #ccc; } 
+        
+        .row-80 td { border-top: 2px solid #e0e0e0; }
+        .row-60 td { border-bottom: 1px solid #ccc; }
+
+        /* Highlights */
+        .cell-gap-blue { background-color: #e1f5fe !important; color: #0277bd; font-weight: bold; } 
+        .cell-gap-orange { background-color: #fbe9e7 !important; color: #d84315; font-weight: bold; } 
+        .cell-zero { color: #e0e0e0; }
+
+        .clickable { cursor: pointer; font-weight: 500; }
+        .clickable:hover { box-shadow: inset 0 0 0 2px #1a73e8; background-color: #f8f9fa; }
+        
+        .cell-gap-blue:hover { background-color: #b3e5fc !important; }
+        .cell-gap-orange:hover { background-color: #ffccbc !important; }
+
+        /* Legend */
+        .legend { font-size: 0.85rem; display: flex; gap: 15px; align-items: center; flex-wrap: wrap;}
+        .legend-item { display: flex; align-items: center; gap: 5px; }
+        .swatch { width: 16px; height: 16px; border-radius: 3px; display: inline-block; border: 1px solid #ddd; }
+
+        /* Status & Modal */
+        #status { padding: 10px; margin-bottom: 15px; border-radius: 4px; display: none; }
+        .loading { background-color: #e8f0fe; color: #1967d2; display: block !important; }
+        .error { background-color: #fce8e6; color: #c5221f; display: block !important; }
+
+        .modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; justify-content: center; align-items: center; }
+        .modal-active { display: flex; }
+        .modal-content { background: white; width: 90%; max-width: 600px; max-height: 80vh; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); display: flex; flex-direction: column; }
+        .modal-header { padding: 16px 20px; border-bottom: 1px solid #ddd; display: flex; justify-content: space-between; align-items: center; background: #f1f3f4; border-radius: 8px 8px 0 0; }
+        .modal-title { margin: 0; font-size: 1.1rem; color: #202124; }
+        .close-btn { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #5f6368; }
+        .modal-body { padding: 20px; overflow-y: auto; }
+        ul.font-list { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; }
+        ul.font-list li a { text-decoration: none; color: #1a73e8; font-size: 0.95rem; display: block; padding: 4px 8px; border-radius: 4px; }
+        ul.font-list li a:hover { background-color: #e8f0fe; }
+    </style>
+</head>
+<body>
+
+    <div class="header-section">
+        <h1>Multi-Threshold Tag Coverage Dashboard</h1>
+        <p class="subtitle">Comparing availability at 80, 70, and 60 quality score thresholds.</p>
+        
+        <div class="legend">
+            <div class="legend-item"><span class="swatch" style="background:#e1f5fe"></span> <strong>Potential (Blue):</strong> These fonts are >70, but 0 fonts exist >80.</div>
+            <div class="legend-item"><span class="swatch" style="background:#fbe9e7"></span> <strong>Weak (Orange):</strong> These fonts are >60, but 0 fonts exist >70.</div>
+        </div>
+
+    </div>
+
+    <div class="table-container">
+        <table id="dashboard">
+            <thead>
+                </thead>
+            <tbody>
+                </tbody>
+        </table>
+    </div>
+
+    <div id="modal" class="modal-overlay">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h2 id="modal-title" class="modal-title">Fonts List</h2>
+                <button class="close-btn" onclick="closeModal()">&times;</button>
+            </div>
+            <div class="modal-body">
+                <ul id="font-list" class="font-list"></ul>
+            </div>
+        </div>
+    </div>
+
+<script>
+    // --- Configuration ---
+    const METADATA_URL = "catalog_metadata.json"; 
+    const TAGS_URL = "https://raw.githubusercontent.com/google/fonts/refs/heads/main/tags/all/families.csv";
+    
+    const WEIGHTS = { "Concept": 0.4, "Drawing": 0.3, "Spacing": 0.2, "Wordspace": 0.1 };
+
+    const ALLOWED_SUBSETS = [
+        "latin", "devanagari", "japanese", "korean", "arabic", "thai", 
+        "bengali", "chinese-simplified", "cyrillic", "greek", "hebrew", 
+        "tamil", "telugu", "gujarati", "kannada", "malayalam", "gurmukhi", 
+        "oriya", "myanmar", "armenian", "sinhala", "georgian", "chinese-traditional"
+    ];
+
+    // --- Main Execution ---
+    document.addEventListener("DOMContentLoaded", async () => {
+        const statusDiv = document.getElementById('status');
+        try {
+            const [metaRes, tagsRes] = await Promise.all([ fetch(METADATA_URL), fetch(TAGS_URL) ]);
+            if (!metaRes.ok) throw new Error(`Metadata Error: ${metaRes.status}`);
+            if (!tagsRes.ok) throw new Error(`Tags CSV Error: ${tagsRes.status}`);
+
+            const metadata = await metaRes.json();
+            const tagsText = await tagsRes.text();
+
+            const parsedData = parseCSVFull(tagsText);
+            const { scores, fontTagsMap, uniqueTags } = parsedData;
+
+            const tableData = aggregateData(metadata.familyMetadataList, fontTagsMap, scores, uniqueTags);
+            
+            renderTable(tableData, uniqueTags);
+            statusDiv.style.display = 'none';
+
+        } catch (err) {
+            console.error(err);
+            statusDiv.textContent = `Error: ${err.message}`;
+            statusDiv.className = 'error';
+        }
+    });
+
+    // --- Data Processing ---
+    function parseCSVFull(csvText) {
+        const lines = csvText.split('\n');
+        const fontTagsMap = {}; 
+        const uniqueTags = new Set();
+        const rawQualityValues = {};
+
+        for (let i = 1; i < lines.length; i++) {
+            const row = lines[i].split(',');
+            if (row.length < 3) continue; 
+
+            const family = row[0].trim();
+            let rawTag = row[2].trim(); 
+            let weightStr = (row[3] || "0").trim();
+            if (!rawTag && row[1]) rawTag = row[1].trim();
+            let weight = parseFloat(weightStr);
+
+            if (rawTag.includes("Quality/")) {
+                const parts = rawTag.split("Quality/");
+                const type = parts[1];
+                if (type && !isNaN(weight)) {
+                    if (!rawQualityValues[family]) rawQualityValues[family] = {};
+                    rawQualityValues[family][type] = weight;
+                }
+            } 
+            else {
+                let cleanTag = rawTag.replace(/^[\/"']+|[\/"']+$/g, '');
+                if (cleanTag) {
+                    uniqueTags.add(cleanTag);
+                    if (!fontTagsMap[family]) fontTagsMap[family] = new Set();
+                    fontTagsMap[family].add(cleanTag);
+                }
+            }
+        }
+
+        const scores = {};
+        for (const [family, categories] of Object.entries(rawQualityValues)) {
+            let totalScore = 0;
+            totalScore += (categories['Concept'] || 0) * WEIGHTS['Concept'];
+            totalScore += (categories['Drawing'] || 0) * WEIGHTS['Drawing'];
+            totalScore += (categories['Spacing'] || 0) * WEIGHTS['Spacing'];
+            totalScore += (categories['Wordspace'] || 0) * WEIGHTS['Wordspace'];
+            scores[family] = totalScore;
+        }
+
+        return { scores, fontTagsMap, uniqueTags: Array.from(uniqueTags).sort() };
+    }
+
+    function aggregateData(fontList, fontTagsMap, scores, uniqueTags) {
+        const data = {};
+        
+        ALLOWED_SUBSETS.forEach(sub => {
+            data[sub] = { 
+                cov70: 0, // Track "Total Tags Covered" at 70 for sorting
+                tagBuckets: {} 
+            };
+            uniqueTags.forEach(tag => {
+                data[sub].tagBuckets[tag] = { t80: [], t70: [], t60: [] };
+            });
+        });
+
+        fontList.forEach(font => {
+            const familyName = font.family;
+            const quality = scores[familyName] || 0;
+            
+            if (quality < 60) return;
+
+            const fontTags = fontTagsMap[familyName]; 
+            if (!fontTags) return; 
+
+            font.subsets.forEach(subset => {
+                if (!data[subset]) return; 
+                
+                fontTags.forEach(tag => {
+                    const bucket = data[subset].tagBuckets[tag];
+                    if (!bucket) return;
+
+                    if (quality >= 60) bucket.t60.push(familyName);
+                    if (quality >= 70) bucket.t70.push(familyName);
+                    if (quality >= 80) bucket.t80.push(familyName);
+                });
+            });
+        });
+
+        // Pre-calculate sums for sorting
+        Object.values(data).forEach(row => {
+            let count70 = 0;
+            uniqueTags.forEach(tag => {
+                const bucket = row.tagBuckets[tag];
+                // "Total Tags Covered at 70": Check if there is AT LEAST ONE font > 70
+                if (bucket.t70.length > 0) count70++;
+            });
+            row.cov70 = count70;
+        });
+
+        return data;
+    }
+
+    // --- Rendering Logic ---
+
+    function createCell(count, higherTierCount = -1, highlightType = null) {
+        const td = document.createElement('td');
+        td.textContent = count;
+        
+        if (count === 0) {
+            td.className = 'cell-zero';
+            td.textContent = "-";
+            return td;
+        }
+
+        td.className = 'clickable';
+
+        if (higherTierCount === 0) {
+            if (highlightType === 'blue') {
+                td.classList.add('cell-gap-blue');
+                td.title = "These fonts are >70, but 0 fonts exist >80";
+            }
+            if (highlightType === 'orange') {
+                td.classList.add('cell-gap-orange');
+                td.title = "These fonts are >60, but 0 fonts exist >70";
+            }
+        }
+
+        return td;
+    }
+
+    function renderTable(data, uniqueTags) {
+        const table = document.getElementById('dashboard');
+        const thead = table.querySelector('thead');
+        const tbody = table.querySelector('tbody');
+
+        let headerHTML = `<tr>
+            <th style="min-width:140px;">Language / Script</th>
+            <th title="Metric">Threshold</th>
+            <th title="Sum of Highlighted Cells">Upgrade<br>Opportunities</th>
+            <th title="Number of tags with fonts present">Total Tags<br>Covered</th>`;
+        
+        uniqueTags.forEach(tag => {
+            headerHTML += `<th title="${tag}">${tag}</th>`;
+        });
+        headerHTML += `</tr>`;
+        thead.innerHTML = headerHTML;
+
+        // Sort rows by "Total Tags Covered" at 70 (cov70)
+        const sortedSubsets = Object.entries(data).sort((a, b) => b[1].cov70 - a[1].cov70);
+
+        sortedSubsets.forEach(([subset, rowData]) => {
+            
+            const tr80 = document.createElement('tr'); tr80.className = 'row-80';
+            const tr70 = document.createElement('tr'); tr70.className = 'row-70';
+            const tr60 = document.createElement('tr'); tr60.className = 'row-60';
+
+            // --- SUM CALCULATION LOGIC ---
+            let sum80 = 0, cov80 = 0;
+            let sum70 = 0, cov70 = 0;
+            let sum60 = 0, cov60 = 0;
+
+            uniqueTags.forEach(tag => {
+                const bucket = rowData.tagBuckets[tag];
+                const c80 = bucket.t80.length;
+                const c70 = bucket.t70.length;
+                const c60 = bucket.t60.length;
+
+                if(c80 > 0) cov80++;
+                if(c70 > 0) cov70++;
+                if(c60 > 0) cov60++;
+
+                if (c70 > 0 && c80 === 0) sum70 += c70;
+                if (c60 > 0 && c70 === 0) sum60 += c60;
+            });
+
+            tr80.innerHTML = `<td>${subset}</td><td style="color:#202124; font-weight:bold;">> 80</td>
+                              <td>-</td><td>${cov80}</td>`;
+                              
+            tr70.innerHTML = `<td> </td><td style="color:#202124; font-weight:bold;">> 70</td>
+                              <td>${sum70}</td><td>${cov70}</td>`;
+
+            tr60.innerHTML = `<td> </td><td style="color:#202124; font-weight:bold;">> 60</td>
+                              <td>${sum60}</td><td>${cov60}</td>`;
+
+            // Append Tag Cells
+            uniqueTags.forEach(tag => {
+                const bucket = rowData.tagBuckets[tag];
+                const c80 = bucket.t80.length;
+                const c70 = bucket.t70.length;
+                const c60 = bucket.t60.length;
+
+                const td80 = createCell(c80, -1, null);
+                if (c80 > 0) td80.onclick = () => openModal(subset, `${tag} (>80)`, bucket.t80);
+                
+                const td70 = createCell(c70, c80, 'blue');
+                if (c70 > 0) td70.onclick = () => openModal(subset, `${tag} (>70)`, bucket.t70);
+
+                const td60 = createCell(c60, c70, 'orange');
+                if (c60 > 0) td60.onclick = () => openModal(subset, `${tag} (>60)`, bucket.t60);
+
+                tr80.appendChild(td80);
+                tr70.appendChild(td70);
+                tr60.appendChild(td60);
+            });
+
+            tbody.appendChild(tr80);
+            tbody.appendChild(tr70);
+            tbody.appendChild(tr60);
+        });
+    }
+
+    const modal = document.getElementById('modal');
+    const modalTitle = document.getElementById('modal-title');
+    const fontListEl = document.getElementById('font-list');
+
+    function openModal(subset, title, fonts) {
+        modalTitle.textContent = `${subset} — ${title} (${fonts.length})`;
+        fontListEl.innerHTML = '';
+        fonts.sort((a, b) => a.localeCompare(b));
+        fonts.forEach(font => {
+            const li = document.createElement('li');
+            const a = document.createElement('a');
+            a.textContent = font;
+            a.href = `https://fonts.google.com/specimen/${font.replace(/ /g, '+')}`;
+            a.target = "_blank";
+            li.appendChild(a);
+            fontListEl.appendChild(li);
+        });
+        modal.classList.add('modal-active');
+    }
+
+    function closeModal() { modal.classList.remove('modal-active'); }
+    modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
+
+</script>
+</body>
+</html>
\ No newline at end of file
diff --git a/.ci/quality_usage.html b/.ci/quality_usage.html
new file mode 100644 (file)
index 0000000..1fd9c97
--- /dev/null
@@ -0,0 +1,599 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Font Usage & Quality Analysis</title>
+    <style>
+        /* --- LAYOUT --- */
+        html, body {
+            height: 100%;
+            margin: 0;
+            overflow: hidden; 
+            background: #f8f9fa; 
+            color: #202124;
+            font-family: 'Google Sans', Roboto, sans-serif; 
+        }
+
+        body {
+            display: flex;
+            flex-direction: column;
+            padding: 20px;
+            box-sizing: border-box;
+        }
+
+        .header-section {
+            flex: 0 0 auto;
+            margin-bottom: 15px;
+        }
+
+        h1 { margin: 0 0 5px 0; }
+        p.subtitle { color: #5f6368; margin: 0 0 15px 0; font-size: 0.9rem; }
+
+        /* The container */
+        .scroll-area { 
+            flex: 1; 
+            overflow: auto; 
+            background: #f8f9fa; 
+            border-radius: 8px; 
+            position: relative;
+        }
+
+        .table-card {
+            background: white;
+            border-radius: 8px;
+            box-shadow: 0 1px 3px rgba(0,0,0,0.2);
+            margin-bottom: 30px;
+            overflow: hidden; 
+        }
+
+        h2.section-title {
+            margin: 0;
+            padding: 15px 20px;
+            background: #e8f0fe;
+            color: #1967d2;
+            font-size: 1rem;
+            border-bottom: 1px solid #d2e3fc;
+        }
+        
+        .unscored-header { background: #f1f3f4 !important; color: #5f6368 !important; border-bottom: 1px solid #ddd !important; }
+
+        table { border-collapse: separate; border-spacing: 0; width: 100%;}
+        
+        /* Headers */
+        th { 
+            background-color: #fff; 
+            position: sticky; top: 0; z-index: 10; 
+            font-weight: 600; color: #5f6368; font-size: 0.75rem; 
+            text-transform: uppercase; letter-spacing: 0.5px; 
+            padding: 12px 16px; border-bottom: 1px solid #ddd; 
+            text-align: left; 
+            white-space: nowrap;
+            cursor: pointer; 
+        }
+        
+        th:hover { background-color: #e8eaed; color: #202124; }
+
+        td { font-size: 0.9rem; padding: 10px 16px; border-bottom: 1px solid #eee; background: white; }
+        td.num { text-align: right; font-variant-numeric: tabular-nums; }
+
+        /* --- HIGHLIGHTING LOGIC --- */
+        tr.row-risk td { background-color: #fce8e6; color: #c5221f; }
+        tr.row-risk td:first-child { font-weight: bold; border-left: 4px solid #c5221f; }
+        
+        tr.row-opportunity td { background-color: #e6f4ea; color: #137333; }
+        tr.row-opportunity td:first-child { font-weight: bold; border-left: 4px solid #137333; }
+
+        /* Legend */
+        .legend { font-size: 0.85rem; display: flex; gap: 20px; align-items: center; flex-wrap: wrap; margin-top: 10px;}
+        .legend-item { display: flex; align-items: center; gap: 6px; }
+        .swatch { width: 16px; height: 16px; border-radius: 3px; display: inline-block; border: 1px solid #ddd; }
+
+        /* Controls Bar */
+        .controls-bar { 
+            margin-top: 15px; 
+            display: flex; 
+            align-items: center; 
+            gap: 30px; 
+            background: white;
+            padding: 10px 15px;
+            border-radius: 8px;
+            box-shadow: 0 1px 2px rgba(0,0,0,0.1);
+        }
+
+        .control-group { display: flex; flex-direction: column; gap: 5px; }
+        .control-title { font-size: 0.75rem; color: #5f6368; font-weight: 600; text-transform: uppercase; }
+        
+        .toggle-label { display: flex; align-items: center; cursor: pointer; font-size: 0.9rem; user-select: none; }
+        .toggle-label input { margin-right: 8px; width: 16px; height: 16px; }
+
+        /* Radio Group */
+        .radio-group { display: flex; gap: 15px; }
+        .radio-label { display: flex; align-items: center; cursor: pointer; font-size: 0.9rem; }
+        .radio-label input { margin-right: 6px; }
+
+        /* Links & Interactions */
+        a.font-link { text-decoration: none; color: inherit; }
+        a.font-link:hover { text-decoration: underline; }
+
+        .clickable-score { 
+            cursor: pointer; 
+            text-decoration: underline; 
+            text-decoration-style: dotted; 
+            text-underline-offset: 3px;
+            color: #1a73e8;
+        }
+        .clickable-score:hover {
+            background-color: rgba(0,0,0,0.05);
+            color: #1557b0;
+        }
+
+        #status { padding: 10px; margin-bottom: 15px; border-radius: 4px; display: none; }
+        .loading { background-color: #e8f0fe; color: #1967d2; display: block !important; }
+        .error { background-color: #fce8e6; color: #c5221f; display: block !important; }
+
+        /* --- MODAL STYLES --- */
+        .modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; justify-content: center; align-items: center; }
+        .modal-active { display: flex; }
+        .modal-content { background: white; width: 90%; max-width: 450px; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); display: flex; flex-direction: column; overflow: hidden; }
+        .modal-header { padding: 12px 20px; border-bottom: 1px solid #ddd; display: flex; justify-content: space-between; align-items: center; background: #f1f3f4; }
+        .modal-title { margin: 0; font-size: 1.1rem; color: #202124; }
+        .close-btn { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #5f6368; }
+        .modal-body { padding: 15px 20px; }
+
+        .score-table { width: 100%; border-collapse: collapse; margin-top: 0; font-size: 0.9rem; }
+        .score-table th { background: none; border-bottom: 2px solid #ddd; padding: 6px 4px; text-align: left; color: #5f6368; font-size: 0.75rem; text-transform: uppercase; }
+        .score-table td { padding: 8px 4px; border-bottom: 1px solid #eee; text-align: right; font-variant-numeric: tabular-nums; }
+        .score-table td:first-child { text-align: left; font-weight: 500; width: 35%; }
+        .total-row td { font-weight: bold; border-top: 2px solid #ddd; background-color: #f8f9fa; }
+    </style>
+</head>
+<body>
+
+    <div class="header-section">
+        <h1>Font Usage & Quality Analysis</h1>
+        <p class="subtitle">Comparing 30-day views against amalgamated quality scores.</p>
+        
+        <div class="legend">
+            <div class="legend-item">
+                <span class="swatch" style="background:#fce8e6; border-color:#c5221f;"></span> 
+                <strong>Attention Needed:</strong> Top 50% Usage & Score &lt; 60
+            </div>
+            <div class="legend-item">
+                <span class="swatch" style="background:#e6f4ea; border-color:#137333;"></span> 
+                <strong>Hidden Gem:</strong> Bottom 50% Usage & Score &gt; 70
+            </div>
+        </div>
+
+        <div class="controls-bar">
+            <div class="control-group">
+                <div class="control-title">Filter View</div>
+                <label class="toggle-label">
+                    <input type="checkbox" id="filterToggle" onchange="renderTables()" checked>
+                    Show Priority Items Only
+                </label>
+            </div>
+
+            <div style="width:1px; height:30px; background:#ddd;"></div>
+
+            <div class="control-group">
+                <div class="control-title">Scoring Model</div>
+                <div class="radio-group">
+                    <label class="radio-label">
+                        <input type="radio" name="scoreMode" value="standard" checked onchange="updateScoringMode()">
+                        Quality Bar (Design Heavy)
+                    </label>
+                    <label class="radio-label">
+                        <input type="radio" name="scoreMode" value="cheap" onchange="updateScoringMode()">
+                        Cheap Fix (Spacing Heavy)
+                    </label>
+                </div>
+            </div>
+        </div>
+
+    </div>
+
+    <div class="scroll-area">
+        
+        <div class="table-card">
+            <table id="main-dashboard">
+                <thead>
+                    <tr>
+                        <th onclick="sortTable('family')">Font Family</th>
+                        <th onclick="sortTable('rank')" class="num">Usage Rank</th>
+                        <th onclick="sortTable('views')" class="num">30 Day Views</th>
+                        <th onclick="sortTable('score')" class="num">Quality Score</th>
+                        <th>Status</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    </tbody>
+            </table>
+        </div>
+
+        <div class="table-card">
+            <h2 class="section-title unscored-header">Unscored Fonts (Quality Score 0)</h2>
+            <table id="unscored-dashboard">
+                <thead>
+                    <tr>
+                        <th style="width:40%">Font Family</th>
+                        <th class="num">30 Day Views</th>
+                        <th class="num">Quality Score</th>
+                        <th>Status</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    </tbody>
+            </table>
+        </div>
+    </div>
+
+    <div id="modal" class="modal-overlay">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h2 id="modal-title" class="modal-title">Score Breakdown</h2>
+                <button class="close-btn" onclick="closeModal()">&times;</button>
+            </div>
+            <div class="modal-body">
+                <div id="breakdown-container"></div>
+            </div>
+        </div>
+    </div>
+
+<script>
+    // --- Configuration ---
+    const METADATA_URL = "catalog_metadata.json";
+    const TAGS_URL = "https://raw.githubusercontent.com/google/fonts/refs/heads/main/tags/all/families.csv";
+    const USAGE_URL = "usage_data.csv"; 
+    
+    // Scoring Models
+    const WEIGHTS_STANDARD = { "Concept": 0.4, "Drawing": 0.3, "Spacing": 0.2, "Wordspace": 0.1 };
+    const WEIGHTS_CHEAP    = { "Concept": 0.1, "Drawing": 0.2, "Spacing": 0.3, "Wordspace": 0.4 };
+
+    // State
+    let ACTIVE_WEIGHTS = WEIGHTS_STANDARD; 
+    
+    // Data Storage
+    let RAW_METADATA = [];
+    let RAW_SCORES = {}; // { Family: { Concept: 80, Drawing: 50... } }
+    let RAW_USAGE = {};
+
+    let SCORED_DATA = [];
+    let UNSCORED_DATA = [];
+
+    // --- Main Execution ---
+    document.addEventListener("DOMContentLoaded", async () => {
+        const statusDiv = document.getElementById('status');
+        try {
+            const [metaRes, tagsRes, usageRes] = await Promise.all([ 
+                fetch(METADATA_URL), 
+                fetch(TAGS_URL),
+                fetch(USAGE_URL)
+            ]);
+
+            if (!metaRes.ok) throw new Error(`Metadata Error: ${metaRes.status}`);
+            if (!tagsRes.ok) throw new Error(`Tags CSV Error: ${tagsRes.status}`);
+            if (!usageRes.ok) throw new Error(`Usage CSV Error: ${usageRes.status}`);
+
+            RAW_METADATA = (await metaRes.json()).familyMetadataList;
+            const tagsText = await tagsRes.text();
+            const usageText = await usageRes.text();
+
+            RAW_SCORES = parseTagsRaw(tagsText);
+            RAW_USAGE = parseUsageCSV(usageText);
+
+            // Initial Calculation
+            recalculateAllData();
+            
+            renderTables();
+            statusDiv.style.display = 'none';
+
+        } catch (err) {
+            console.error(err);
+            statusDiv.innerHTML = `<strong>Error:</strong> ${err.message}<br><small>Note: usage_data.csv must be in the same folder.</small>`;
+            statusDiv.className = 'error';
+        }
+    });
+
+    // --- Parsing Logic ---
+
+    function parseCSVLine(line) {
+        let parts = [];
+        let current = '';
+        let inQuotes = false;
+        
+        for (let i = 0; i < line.length; i++) {
+            let char = line[i];
+            
+            if (char === '"') {
+                inQuotes = !inQuotes; 
+            } else if (char === ',' && !inQuotes) {
+                parts.push(current);
+                current = '';
+            } else {
+                current += char;
+            }
+        }
+        parts.push(current); 
+        return parts.map(p => p.trim().replace(/^"|"$/g, ''));
+    }
+
+    // Get Raw Metric Values only (Score calc happens later)
+    function parseTagsRaw(csvText) {
+        const lines = csvText.split('\n');
+        const rawValues = {}; 
+
+        for (let i = 1; i < lines.length; i++) {
+            const line = lines[i].trim();
+            if(!line) continue;
+            
+            const parts = parseCSVLine(line);
+            if (parts.length < 3) continue; 
+
+            const family = parts[0]; 
+            let rawTag = parts[2];
+            let weightStr = parts[3];
+
+            if (!rawTag && parts[1] && parts[1].includes('/')) {
+                rawTag = parts[1];
+                weightStr = parts[2];
+            }
+
+            let weight = parseFloat(weightStr || 0);
+
+            if (rawTag && rawTag.includes("Quality/")) {
+                const type = rawTag.split("Quality/")[1];
+                if (type && !isNaN(weight)) {
+                    if (!rawValues[family]) rawValues[family] = {};
+                    rawValues[family][type] = weight;
+                }
+            }
+        }
+        return rawValues;
+    }
+
+    function parseUsageCSV(csvText) {
+        const lines = csvText.split('\n');
+        const usage = {}; 
+
+        for (let i = 0; i < lines.length; i++) {
+            const line = lines[i].trim();
+            if (!line) continue;
+
+            const parts = parseCSVLine(line);
+            if (parts.length < 2) continue;
+
+            const family = parts[0];
+            const viewString = parts[1].replace(/,/g, '');
+            const views = parseInt(viewString);
+
+            if (!isNaN(views)) {
+                usage[family] = views;
+            }
+        }
+        return usage;
+    }
+
+    // --- Core Logic ---
+
+    function updateScoringMode() {
+        const mode = document.querySelector('input[name="scoreMode"]:checked').value;
+        ACTIVE_WEIGHTS = (mode === 'cheap') ? WEIGHTS_CHEAP : WEIGHTS_STANDARD;
+        
+        recalculateAllData();
+        renderTables();
+    }
+
+    function recalculateAllData() {
+        let scored = [];
+        let unscored = [];
+
+        RAW_METADATA.forEach(font => {
+            const family = font.family;
+            const views = RAW_USAGE[family] || 0;
+            const breakdown = RAW_SCORES[family] || {}; 
+
+            // Calculate Score based on ACTIVE_WEIGHTS
+            let finalScore = 0;
+            finalScore += (breakdown['Concept'] || 0) * ACTIVE_WEIGHTS['Concept'];
+            finalScore += (breakdown['Drawing'] || 0) * ACTIVE_WEIGHTS['Drawing'];
+            finalScore += (breakdown['Spacing'] || 0) * ACTIVE_WEIGHTS['Spacing'];
+            finalScore += (breakdown['Wordspace'] || 0) * ACTIVE_WEIGHTS['Wordspace'];
+
+            const row = { family, views, score: finalScore, breakdown };
+
+            if (finalScore === 0) {
+                unscored.push(row);
+            } else {
+                scored.push(row);
+            }
+        });
+
+        // Rank and Status Logic
+        scored.sort((a, b) => b.views - a.views); // Sort by Views for Ranking
+
+        const medianIndex = Math.floor(scored.length / 2);
+
+        SCORED_DATA = scored.map((row, index) => {
+            const rank = index + 1;
+            const isTopHalf = index < medianIndex;
+            
+            let status = "Normal";
+            let type = "normal";
+
+            if (isTopHalf && row.score < 60) {
+                status = "High Usage, Low Quality";
+                type = "risk";
+            } else if (!isTopHalf && row.score > 70) {
+                status = "Low Usage, High Quality";
+                type = "opportunity";
+            }
+
+            return { ...row, rank, type, status };
+        });
+
+        UNSCORED_DATA = unscored.sort((a, b) => b.views - a.views);
+    }
+
+    // --- Rendering ---
+
+    function renderTables() {
+        const mainTbody = document.querySelector('#main-dashboard tbody');
+        const unscoredTbody = document.querySelector('#unscored-dashboard tbody');
+        
+        mainTbody.innerHTML = '';
+        unscoredTbody.innerHTML = '';
+
+        const filterActive = document.getElementById('filterToggle').checked;
+
+        SCORED_DATA.forEach(row => {
+            if (filterActive && row.type === 'normal') return;
+
+            const tr = document.createElement('tr');
+            if (row.type === 'risk') tr.className = 'row-risk';
+            if (row.type === 'opportunity') tr.className = 'row-opportunity';
+
+            const viewsStr = row.views.toLocaleString();
+            const scoreStr = row.score.toFixed(1);
+            
+            const safeFamily = row.family.replace(/'/g, "\\'");
+            const scoreCell = `<span class="clickable-score" onclick="openScoreModal('${safeFamily}')">${scoreStr}</span>`;
+
+            tr.innerHTML = `
+                <td><a href="https://fonts.google.com/specimen/${row.family.replace(/ /g, '+')}" class="font-link" target="_blank">${row.family}</a></td>
+                <td class="num">#${row.rank}</td>
+                <td class="num">${viewsStr}</td>
+                <td class="num">${scoreCell}</td>
+                <td>${row.status}</td>
+            `;
+            mainTbody.appendChild(tr);
+        });
+
+        UNSCORED_DATA.forEach(row => {
+            const tr = document.createElement('tr');
+            const viewsStr = row.views.toLocaleString();
+
+            tr.innerHTML = `
+                <td><a href="https://fonts.google.com/specimen/${row.family.replace(/ /g, '+')}" class="font-link" target="_blank">${row.family}</a></td>
+                <td class="num">${viewsStr}</td>
+                <td class="num" style="color:#aaa;">0.0</td>
+                <td style="color:#aaa;">Pending Scoring</td>
+            `;
+            unscoredTbody.appendChild(tr);
+        });
+    }
+
+    // --- Modal Logic ---
+    function openScoreModal(familyName) {
+        const font = SCORED_DATA.find(f => f.family === familyName) || UNSCORED_DATA.find(f => f.family === familyName);
+        if (!font) return;
+
+        const modal = document.getElementById('modal');
+        const modalTitle = document.getElementById('modal-title');
+        const container = document.getElementById('breakdown-container');
+
+        modalTitle.textContent = `${font.family} Breakdown`;
+
+        const c = font.breakdown['Concept'] || 0;
+        const d = font.breakdown['Drawing'] || 0;
+        const s = font.breakdown['Spacing'] || 0;
+        const w = font.breakdown['Wordspace'] || 0;
+
+        // Dynamic Calculation based on ACTIVE weights
+        const c_val = (c * ACTIVE_WEIGHTS['Concept']).toFixed(1);
+        const d_val = (d * ACTIVE_WEIGHTS['Drawing']).toFixed(1);
+        const s_val = (s * ACTIVE_WEIGHTS['Spacing']).toFixed(1);
+        const w_val = (w * ACTIVE_WEIGHTS['Wordspace']).toFixed(1);
+        
+        const total = font.score.toFixed(1);
+
+        // Convert weights to percentage for display
+        const c_pct = (ACTIVE_WEIGHTS['Concept'] * 100) + '%';
+        const d_pct = (ACTIVE_WEIGHTS['Drawing'] * 100) + '%';
+        const s_pct = (ACTIVE_WEIGHTS['Spacing'] * 100) + '%';
+        const w_pct = (ACTIVE_WEIGHTS['Wordspace'] * 100) + '%';
+
+        container.innerHTML = `
+            <table class="score-table">
+                <thead>
+                    <tr>
+                        <th>Metric</th>
+                        <th>Raw</th>
+                        <th>Wgt</th>
+                        <th>Val</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    <tr>
+                        <td>Concept</td>
+                        <td>${c}</td>
+                        <td>${c_pct}</td>
+                        <td>${c_val}</td>
+                    </tr>
+                    <tr>
+                        <td>Drawing</td>
+                        <td>${d}</td>
+                        <td>${d_pct}</td>
+                        <td>${d_val}</td>
+                    </tr>
+                    <tr>
+                        <td>Spacing</td>
+                        <td>${s}</td>
+                        <td>${s_pct}</td>
+                        <td>${s_val}</td>
+                    </tr>
+                    <tr>
+                        <td>Wordspace</td>
+                        <td>${w}</td>
+                        <td>${w_pct}</td>
+                        <td>${w_val}</td>
+                    </tr>
+                    <tr class="total-row">
+                        <td>Total</td>
+                        <td></td>
+                        <td></td>
+                        <td>${total}</td>
+                    </tr>
+                </tbody>
+            </table>
+        `;
+
+        modal.classList.add('modal-active');
+    }
+
+    function closeModal() {
+        document.getElementById('modal').classList.remove('modal-active');
+    }
+
+    document.getElementById('modal').addEventListener('click', (e) => {
+        if (e.target === document.getElementById('modal')) closeModal();
+    });
+
+    // --- Sorting ---
+    let currentSort = { col: 'views', dir: 'desc' };
+
+    function sortTable(col) {
+        if (currentSort.col === col) {
+            currentSort.dir = currentSort.dir === 'asc' ? 'desc' : 'asc';
+        } else {
+            currentSort.col = col;
+            currentSort.dir = 'desc'; 
+        }
+
+        SCORED_DATA.sort((a, b) => {
+            let valA = a[col];
+            let valB = b[col];
+
+            if (typeof valA === 'string') {
+                return currentSort.dir === 'asc' 
+                    ? valA.localeCompare(valB) 
+                    : valB.localeCompare(valA);
+            }
+            return currentSort.dir === 'asc' ? valA - valB : valB - valA;
+        });
+
+        renderTables();
+    }
+</script>
+</body>
+</html>
\ No newline at end of file