Files
protocol-bicorder/bicorder-app/src/App.svelte
T
Protocolbot 8c40ca076b feat: remove formal/informal LDA analysis from the bicorder (v1.3.0)
The two-family cluster classification is a research finding, not a
diagnostic; embedding it in the tool caused recurring bugs:

- ascii_bicorder.py had an inverted LDA sign (institutional mapped to 9,
  not 1) and a stale term check ('bureaucratic') that silently disabled
  the calculation after the Feb 2026 rename — output was always null
- The web app and Python script had divergent sign conventions and
  divergent version-mismatch behavior (skip vs. continue with stale model)

Changes:
- bicorder.json: drop the formal/informal analysis gradient; version 1.3.0
- ascii_bicorder.py: remove all LDA/model machinery; keep hardness and
  polarization as the only automated analyses
- App.svelte: remove classifier import, model constant, LDA calculation,
  and form-recommendation reactive block; dispatch automated analyses by
  term_left instead of array index
- Delete bicorder-classifier.ts and FormRecommendation.svelte (the latter
  was imported but never rendered)
- AnalysisTransitionBanner: remove recommendation alert and prop; fix
  hardcoded index checks that referenced the removed gradient
- vite.config.ts / vite-env.d.ts: stop loading bicorder_model.json

The cluster classifier lives on as research in analysis/ (scripts and
model untouched there). bicorder.txt regenerated.
2026-09-23 07:58:11 -06:00

931 lines
25 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import type { BicorderState, Gradient, AnalysisGradient } from './types';
import GradientSlider from './components/GradientSlider.svelte';
import MetadataFields from './components/MetadataFields.svelte';
import AnalysisDisplay from './components/AnalysisDisplay.svelte';
import ExportControls from './components/ExportControls.svelte';
import HelpModal from './components/HelpModal.svelte';
import AnalysisTransitionBanner from './components/AnalysisTransitionBanner.svelte';
import HamburgerMenu from './components/HamburgerMenu.svelte';
import Landing from './components/Landing.svelte';
// Load bicorder data and model from build-time constants
let data: BicorderState = JSON.parse(JSON.stringify(__BICORDER_DATA__));
// Initialize timestamp if not set
if (!data.metadata.timestamp) {
data.metadata.timestamp = new Date().toISOString();
}
// View mode and navigation state
type ViewMode = 'focused' | 'list';
let viewMode: ViewMode = 'focused'; // Focused is default
let currentScreen = 0;
let refreshKey = 0; // Used to force component refresh in focused mode
let isHelpOpen = false;
// Show the landing screen on first arrival. Returning users who already have
// a reading in progress skip straight to the diagnostic. Computed
// synchronously (not in onMount) so the landing never flashes for them.
function hasReadingInProgress(): boolean {
if (typeof localStorage === 'undefined') return false;
try {
const saved = localStorage.getItem('bicorder-state');
if (!saved) return false;
const s = JSON.parse(saved);
const hasProtocol = !!s?.metadata?.protocol;
const hasValue = s?.diagnostic?.some(
(set: any) => set?.gradients?.some((g: any) => g?.value !== null && g?.value !== undefined)
);
return hasProtocol || hasValue;
} catch {
return false;
}
}
let started = hasReadingInProgress();
function startReading() {
started = true;
currentScreen = 0;
}
// Screen types
type Screen =
| { type: 'metadata' }
| { type: 'gradient'; setIndex: number; gradientIndex: number; gradient: Gradient; setName: string }
| { type: 'analysis'; index: number; gradient: AnalysisGradient }
| { type: 'export' };
// Calculate all screens based on current shortform setting
function calculateScreens(isShortForm: boolean): Screen[] {
const screens: Screen[] = [];
// Metadata screen
screens.push({ type: 'metadata' });
// Diagnostic gradient screens
data.diagnostic.forEach((diagnosticSet, setIndex) => {
diagnosticSet.gradients.forEach((gradient, gradientIndex) => {
if (!isShortForm || gradient.shortform) {
screens.push({
type: 'gradient',
setIndex,
gradientIndex,
gradient,
setName: diagnosticSet.set_name
});
}
});
});
// Analysis screens (shown in both shortform and longform)
// Show the useful gradient first, then the automated ones
const analysisOrder = [2, 0, 1]; // useful, hardness, polarization
analysisOrder.forEach((index) => {
if (index < data.analysis.length) {
screens.push({ type: 'analysis', index, gradient: data.analysis[index] });
}
});
// Export screen
screens.push({ type: 'export' });
return screens;
}
// Recalculate screens when data or shortform changes (explicit dependency)
$: screens = calculateScreens(data.metadata.shortform);
$: currentScreenData = screens[currentScreen];
$: totalScreens = screens.length;
function goToNextScreen() {
if (currentScreen < totalScreens - 1) {
currentScreen++;
}
}
function goToPrevScreen() {
if (currentScreen > 0) {
currentScreen--;
}
}
function toggleViewMode() {
viewMode = viewMode === 'focused' ? 'list' : 'focused';
}
function toggleFormMode() {
data = {
...data,
metadata: {
...data.metadata,
shortform: !data.metadata.shortform
}
};
// Force refresh of components
refreshKey++;
// Reset to first screen when toggling
currentScreen = 0;
}
function showAnalysis() {
// Find the first analysis screen
const firstAnalysisIndex = screens.findIndex(s => s.type === 'analysis');
if (firstAnalysisIndex !== -1) {
currentScreen = firstAnalysisIndex;
// Switch to focused mode if in list mode
if (viewMode === 'list') {
viewMode = 'focused';
}
}
}
// Generate ASCII progress bar
function generateProgressBar(current: number, total: number): string {
const filled = '#';
const empty = '-';
const barLength = Math.min(total, 20); // Cap at 20 characters for display
const filledCount = Math.round((current / total) * barLength);
const emptyCount = barLength - filledCount;
return filled.repeat(filledCount) + empty.repeat(emptyCount);
}
// Calculate total diagnostic screens (metadata + gradients only)
$: diagnosticScreenCount = screens.filter(s => s.type === 'metadata' || s.type === 'gradient').length;
// Calculate current position within diagnostic screens
$: diagnosticProgress = currentScreenData?.type === 'metadata' || currentScreenData?.type === 'gradient'
? currentScreen + 1
: diagnosticScreenCount; // Show as complete when in analysis or export
$: progressBar = generateProgressBar(diagnosticProgress, diagnosticScreenCount);
// Detect if we're on the first analysis screen
$: isFirstAnalysisScreen = currentScreenData?.type === 'analysis' &&
screens.findIndex(s => s.type === 'analysis') === currentScreen;
// Calculate completed gradients for the banner
$: completedGradientsCount = data.diagnostic
.flatMap(set => set.gradients)
.filter(g => !data.metadata.shortform || g.shortform)
.filter(g => g.value !== null).length;
$: totalGradientsCount = data.diagnostic
.flatMap(set => set.gradients)
.filter(g => !data.metadata.shortform || g.shortform).length;
// Load saved state from localStorage
onMount(() => {
const saved = localStorage.getItem('bicorder-state');
if (saved) {
try {
const savedData = JSON.parse(saved);
// Preserve the structure but update values
data.metadata = { ...data.metadata, ...savedData.metadata };
// Update gradient values
data.diagnostic.forEach((set, setIdx) => {
set.gradients.forEach((gradient, gradIdx) => {
const savedGradient = savedData.diagnostic?.[setIdx]?.gradients?.[gradIdx];
if (savedGradient) {
gradient.value = savedGradient.value;
gradient.notes = savedGradient.notes;
}
});
});
// Update analysis values
data.analysis.forEach((item, idx) => {
const savedItem = savedData.analysis?.[idx];
if (savedItem) {
item.notes = savedItem.notes;
}
});
} catch (e) {
console.error('Failed to load saved state:', e);
}
}
// Keyboard navigation for focused mode
function handleKeyDown(e: KeyboardEvent) {
if (viewMode !== 'focused') return;
// Only navigate if not typing in an input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
goToNextScreen();
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
goToPrevScreen();
}
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
});
// Save state whenever it changes
$: {
if (typeof window !== 'undefined') {
localStorage.setItem('bicorder-state', JSON.stringify(data));
}
}
// Auto-calculate analysis values
function calculateHardness(): number | null {
const values = data.diagnostic
.flatMap(set => set.gradients)
.filter(g => !data.metadata.shortform || g.shortform)
.map(g => g.value)
.filter((v): v is number => v !== null);
if (values.length === 0) return null;
const mean = values.reduce((sum, v) => sum + v, 0) / values.length;
return Math.round(mean);
}
function calculatePolarization(): number | null {
const values = data.diagnostic
.flatMap(set => set.gradients)
.filter(g => !data.metadata.shortform || g.shortform)
.map(g => g.value)
.filter((v): v is number => v !== null);
if (values.length === 0) return null;
// Calculate how far values are from center (5)
const deviations = values.map(v => Math.abs(v - 5));
const avgDeviation = deviations.reduce((sum, d) => sum + d, 0) / deviations.length;
// Map deviation to polarized (1) vs centrist (9) scale
// Max deviation is 4 (from 1 or 9), min is 0 (at 5)
// Higher deviation = more polarized = lower value
const polarizationScore = 9 - (avgDeviation / 4) * 8;
// Clamp to 1-9 range and round
return Math.round(Math.max(1, Math.min(9, polarizationScore)));
}
// Update automated analysis values reactively
// Note: the formal/informal (LDA classifier) analysis was removed from the
// bicorder in v1.3.0. Cluster classification lives on as research in
// analysis/ (see scripts/bicorder_classifier.py).
$: {
data.analysis.forEach((item, index) => {
if (item.automated) {
if (item.term_left === 'hardness') {
item.value = calculateHardness();
} else if (item.term_left === 'polarized') {
item.value = calculatePolarization();
}
}
});
}
function handleMetadataUpdate(event: CustomEvent) {
// Properly trigger reactivity for nested metadata changes
data = {
...data,
metadata: { ...data.metadata, ...event.detail }
};
// Force refresh if shortform changed
if (event.detail.shortform !== undefined) {
refreshKey++;
}
}
function handleReset() {
if (confirm('Reset all values? This cannot be undone.')) {
localStorage.removeItem('bicorder-state');
location.reload();
}
}
function handleProgressBarClick(event: MouseEvent) {
// Calculate which screen to jump to based on click position
const target = event.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const x = event.clientX - rect.left;
const width = rect.width;
// Calculate ratio (0 to 1)
const ratio = Math.max(0, Math.min(1, x / width));
// Calculate target screen (round to nearest)
const targetScreen = Math.round(ratio * (totalScreens - 1));
// Jump to that screen
currentScreen = targetScreen;
}
function openHelp() {
isHelpOpen = true;
}
function handleSwitchToLongForm() {
// Turn off shortform mode while preserving all entered values
data = {
...data,
metadata: {
...data.metadata,
shortform: false
}
};
// Force refresh of components
refreshKey++;
// Reset to first screen to show user the full form
currentScreen = 0;
}
</script>
<HelpModal bind:isOpen={isHelpOpen} />
{#if !started}
<Landing on:begin={startReading} on:about={openHelp} />
{:else}
<main>
<div class="header">
<div class="header-left">
<button
class="toolbar-btn help-btn"
on:click={openHelp}
aria-label="About the Bicorder"
title="About"
>
?
</button>
</div>
<div class="header-center">
<div class="title">Protocol</div>
<div class="title">BICORDER</div>
</div>
<div class="header-right">
<HamburgerMenu
{viewMode}
isShortForm={data.metadata.shortform}
on:toggleViewMode={toggleViewMode}
on:toggleFormMode={toggleFormMode}
on:showAnalysis={showAnalysis}
/>
</div>
</div>
{#if viewMode === 'list'}
<!-- LIST MODE: Show all sections -->
<div class="description">
<p>A diagnostic tool for the study of protocols</p>
</div>
<MetadataFields
metadata={data.metadata}
on:update={handleMetadataUpdate}
/>
{#each data.diagnostic as diagnosticSet, setIndex}
<section class="diagnostic-set">
<div class="set-header">{diagnosticSet.set_name.toUpperCase()}</div>
<div class="set-description">{diagnosticSet.set_description}</div>
{#each diagnosticSet.gradients as gradient, gradientIndex}
{#if !data.metadata.shortform || gradient.shortform}
<GradientSlider
{gradient}
on:change={(e) => {
data.diagnostic[setIndex].gradients[gradientIndex].value = e.detail ?? null;
data = data;
}}
on:notes={(e) => {
data.diagnostic[setIndex].gradients[gradientIndex].notes = e.detail;
data = data;
}}
/>
{/if}
{/each}
</section>
{/each}
<section class="analysis-section">
<div class="set-header">ANALYSIS</div>
{#each data.analysis as analysisItem, index}
<AnalysisDisplay
gradient={analysisItem}
on:change={(e) => {
if (!analysisItem.automated) {
data.analysis[index].value = e.detail ?? null;
data = data;
}
}}
on:notes={(e) => {
data.analysis[index].notes = e.detail;
data = data;
}}
/>
{/each}
</section>
<ExportControls {data} on:reset={handleReset} />
{:else}
<!-- FOCUSED MODE: Show one screen at a time -->
<div class="focused-container">
{#if currentScreenData.type === 'metadata'}
<div class="focused-screen">
<MetadataFields
metadata={data.metadata}
on:update={handleMetadataUpdate}
/>
</div>
{:else if currentScreenData.type === 'gradient'}
{@const screen = currentScreenData}
<div class="focused-screen gradient-screen">
<div class="screen-category">{screen.setName.toUpperCase()}</div>
<div class="gradient-focused">
<div class="term-desc left-desc">
<div class="term-name">← {screen.gradient.term_left}</div>
<div class="description-text">{screen.gradient.term_left_description}</div>
</div>
{#key refreshKey}
<GradientSlider
gradient={screen.gradient}
focusedMode={true}
on:change={(e) => {
data.diagnostic[screen.setIndex].gradients[screen.gradientIndex].value = e.detail ?? null;
data = data;
refreshKey++; // Force component refresh
}}
on:notes={(e) => {
data.diagnostic[screen.setIndex].gradients[screen.gradientIndex].notes = e.detail;
data = data;
refreshKey++; // Force component refresh
}}
/>
{/key}
<div class="term-desc right-desc">
<div class="term-name">{screen.gradient.term_right} →</div>
<div class="description-text">{screen.gradient.term_right_description}</div>
</div>
</div>
</div>
{:else if currentScreenData.type === 'analysis'}
{@const screen = currentScreenData}
<div class="focused-screen gradient-screen">
<div class="screen-category">ANALYSIS</div>
{#if screen.gradient.automated}
<div class="analysis-auto-note">auto-calculated</div>
{/if}
{#if isFirstAnalysisScreen}
<AnalysisTransitionBanner
isShortForm={data.metadata.shortform}
completedGradients={completedGradientsCount}
allAnalysisGradients={data.analysis}
on:switchToLongForm={handleSwitchToLongForm}
on:jumpToExport={() => {
// Jump to the export screen (last screen)
currentScreen = screens.length - 1;
}}
on:updateAnalysis={(e) => {
data.analysis[e.detail.index].value = e.detail.value;
data = data;
}}
on:updateAnalysisNotes={(e) => {
data.analysis[e.detail.index].notes = e.detail.notes;
data = data;
}}
/>
{/if}
<div class="gradient-focused">
<div class="term-desc left-desc">
<div class="term-name">← {screen.gradient.term_left}</div>
<div class="description-text">{screen.gradient.term_left_description}</div>
</div>
{#key refreshKey}
<AnalysisDisplay
gradient={screen.gradient}
focusedMode={true}
on:change={(e) => {
if (!screen.gradient.automated) {
data.analysis[screen.index].value = e.detail ?? null;
data = data;
refreshKey++; // Force component refresh
}
}}
on:notes={(e) => {
data.analysis[screen.index].notes = e.detail;
data = data;
refreshKey++; // Force component refresh
}}
/>
{/key}
<div class="term-desc right-desc">
<div class="term-name">{screen.gradient.term_right} →</div>
<div class="description-text">{screen.gradient.term_right_description}</div>
</div>
</div>
</div>
{:else if currentScreenData.type === 'export'}
<div class="focused-screen">
<ExportControls {data} focusedMode={true} on:reset={handleReset} />
</div>
{/if}
</div>
<!-- Navigation and Progress -->
<div class="focused-navigation" class:no-border={currentScreenData.type === 'export'}>
<div class="nav-buttons">
<button
class="nav-btn prev-btn"
on:click={goToPrevScreen}
disabled={currentScreen === 0}
aria-label="Previous screen"
>
← Previous
</button>
<button
class="nav-btn next-btn"
on:click={goToNextScreen}
disabled={currentScreen === totalScreens - 1}
aria-label="Next screen"
>
Next →
</button>
</div>
<div class="progress-indicator">
<div
class="progress-bar clickable"
on:click={handleProgressBarClick}
role="slider"
aria-label="Jump to screen"
aria-valuemin={0}
aria-valuemax={totalScreens - 1}
aria-valuenow={currentScreen}
title="Click to jump to a screen"
>
{progressBar}
</div>
<div class="progress-numbers">{diagnosticProgress} / {diagnosticScreenCount}</div>
</div>
</div>
{/if}
</main>
{/if}
<style>
main {
max-width: 800px;
margin: 0 auto;
padding: 1rem;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.header {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
margin-bottom: 1rem;
border-bottom: 2px solid var(--border-color);
padding-bottom: 1rem;
gap: 1rem;
}
.header-left {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 0.5rem;
}
.header-center {
text-align: center;
}
.header-right {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 0.5rem;
}
.title {
font-size: 1.5rem;
font-weight: bold;
letter-spacing: 0.2rem;
}
.toolbar-btn {
width: 2rem;
height: 2rem;
padding: 0;
font-size: 1rem;
font-weight: bold;
cursor: pointer;
background: var(--bg-color);
color: var(--fg-color);
border: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: center;
opacity: 0.4;
transition: opacity 0.2s, background-color 0.2s;
min-height: auto;
flex-shrink: 0;
border-radius: 3px;
}
.toolbar-btn.help-btn {
font-size: 1.2rem;
}
.toolbar-btn:hover {
opacity: 0.8;
background-color: var(--input-bg);
}
.description {
text-align: center;
padding: 1rem;
margin-bottom: 2rem;
border: 1px solid var(--border-color);
background-color: var(--input-bg);
font-size: 0.9rem;
line-height: 1.6;
}
.description p {
margin: 0;
}
.diagnostic-set, .analysis-section {
margin: 2rem 0;
padding: 1rem 0;
border-top: 2px solid var(--border-color);
}
.set-header {
text-align: center;
font-size: 1.2rem;
font-weight: bold;
letter-spacing: 0.2rem;
margin-bottom: 0.5rem;
}
.set-description {
text-align: center;
font-size: 0.9rem;
margin-bottom: 1.5rem;
opacity: 0.8;
}
/* Focused Mode Styles */
.focused-container {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 400px;
padding: 2rem 0;
padding-bottom: 180px; /* Space for fixed navigation */
}
.focused-screen {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.gradient-screen {
max-width: 700px;
margin: 0 auto;
width: 100%;
}
.screen-category {
text-align: center;
font-size: 1rem;
font-weight: bold;
letter-spacing: 0.2rem;
margin-bottom: 2rem;
opacity: 0.6;
}
.analysis-auto-note {
text-align: center;
color: #888888;
font-size: 0.85rem;
font-style: italic;
margin-top: -1.5rem;
margin-bottom: 2rem;
}
.gradient-focused {
padding: 0;
display: flex;
flex-direction: column;
gap: 2rem;
}
.term-desc {
max-width: 100%;
}
.term-desc.left-desc {
text-align: left;
}
.term-desc.right-desc {
text-align: right;
}
.term-name {
font-weight: bold;
font-size: 1.1rem;
margin-bottom: 0.75rem;
color: var(--fg-color);
}
.description-text {
font-size: 0.85rem;
line-height: 1.5;
opacity: 0.8;
color: var(--fg-color);
}
.focused-navigation {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--bg-color);
border-top: 2px solid var(--border-color);
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
z-index: 100;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
}
.focused-navigation.no-border {
border-top: 2px solid var(--border-color);
}
.nav-buttons {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
max-width: 800px;
margin: 0 auto;
width: 100%;
}
.nav-btn {
padding: 0.75rem 1.5rem;
font-size: 1rem;
flex: 1;
}
.nav-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.progress-indicator {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
max-width: 800px;
margin: 0 auto;
width: 100%;
}
.progress-bar {
font-family: 'Courier New', monospace;
font-size: 1rem;
letter-spacing: 0.1rem;
font-weight: bold;
color: var(--fg-color);
}
.progress-bar.clickable {
cursor: pointer;
user-select: none;
transition: opacity 0.2s, transform 0.1s;
padding: 0.5rem;
margin: -0.5rem;
}
.progress-bar.clickable:hover {
opacity: 0.7;
transform: scale(1.05);
}
.progress-bar.clickable:active {
transform: scale(0.98);
}
.progress-numbers {
font-size: 0.9rem;
font-weight: bold;
letter-spacing: 0.1rem;
opacity: 0.7;
}
@media (max-width: 900px) {
main {
padding: 0.5rem;
}
.header {
gap: 0.5rem;
padding-bottom: 0.75rem;
}
.title {
font-size: 1.2rem;
}
.set-header {
font-size: 1rem;
}
.toolbar-btn {
width: 1.5rem;
height: 1.5rem;
font-size: 0.85rem;
}
.toolbar-btn.help-btn {
font-size: 0.9rem;
}
.description {
padding: 0.75rem;
font-size: 0.85rem;
margin-bottom: 1.5rem;
}
.focused-container {
padding: 1rem 0;
padding-bottom: 160px; /* Adjust for smaller mobile nav */
}
.gradient-focused {
padding: 0;
gap: 1.5rem;
}
.focused-navigation {
padding: 0.75rem;
}
.nav-btn {
flex: 1;
padding: 0.5rem 1rem;
font-size: 0.9rem;
}
.progress-bar {
font-size: 0.85rem;
}
.progress-numbers {
font-size: 0.8rem;
}
}
</style>