c571bf1c01
Introduce a simple, show-don't-tell entry screen that lowers the "what counts as a protocol" barrier and frames the activity as a short journey: - Lead line "Examine the protocols around you" over a rotating, cross-fading list of deliberately diverse examples (traffic, code, kitchens, ritual, governance, play) - Prominent "Begin" CTA above a three-step path: Describe / Understand / Share - "About the Bicorder" link opens the existing help modal Shown on first arrival; returning users with a reading in progress skip straight to the diagnostic (computed synchronously, no flash). Remove the now-redundant inline description from the focused flow. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1045 lines
29 KiB
Svelte
1045 lines
29 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 FormRecommendation from './components/FormRecommendation.svelte';
|
|
import AnalysisTransitionBanner from './components/AnalysisTransitionBanner.svelte';
|
|
import HamburgerMenu from './components/HamburgerMenu.svelte';
|
|
import Landing from './components/Landing.svelte';
|
|
import { BicorderClassifier } from './bicorder-classifier';
|
|
|
|
// Load bicorder data and model from build-time constants
|
|
let data: BicorderState = JSON.parse(JSON.stringify(__BICORDER_DATA__));
|
|
const model = __BICORDER_MODEL__;
|
|
|
|
// Initialize classifier
|
|
const classifier = new BicorderClassifier(model, data.version);
|
|
|
|
// 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 (index 3), then the others
|
|
const analysisOrder = [3, 0, 1, 2]; // useful, hardness, polarization, formal/informal
|
|
analysisOrder.forEach((index) => {
|
|
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;
|
|
|
|
// Calculate form recommendation (shared by FormRecommendation and AnalysisTransitionBanner)
|
|
let formRecommendation: any = null;
|
|
let hasEnoughDataForRecommendation = false;
|
|
|
|
$: {
|
|
// Collect ratings from diagnostic data
|
|
const ratings: Record<string, number> = {};
|
|
let valueCount = 0;
|
|
let shortFormTotal = 0;
|
|
|
|
data.diagnostic.forEach((diagnosticSet) => {
|
|
const setName = diagnosticSet.set_name;
|
|
diagnosticSet.gradients.forEach((gradient) => {
|
|
// Count shortform gradients
|
|
if (gradient.shortform) {
|
|
shortFormTotal++;
|
|
}
|
|
|
|
if (gradient.value !== null) {
|
|
const dimensionName = `${setName}_${gradient.term_left}_vs_${gradient.term_right}`;
|
|
ratings[dimensionName] = gradient.value;
|
|
|
|
// Only count shortform values for the threshold
|
|
if (gradient.shortform) {
|
|
valueCount++;
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// Only calculate if at least half of shortform gradients are complete
|
|
const threshold = Math.ceil(shortFormTotal / 2);
|
|
hasEnoughDataForRecommendation = valueCount >= threshold;
|
|
|
|
if (hasEnoughDataForRecommendation && data.metadata.shortform) {
|
|
try {
|
|
const prediction = classifier.predict(ratings, { detailed: true });
|
|
const assessment = classifier.assessShortFormReadiness(ratings);
|
|
formRecommendation = {
|
|
...prediction,
|
|
...assessment,
|
|
};
|
|
} catch (error) {
|
|
console.error('Error calculating form recommendation:', error);
|
|
formRecommendation = null;
|
|
}
|
|
} else {
|
|
formRecommendation = null;
|
|
}
|
|
}
|
|
|
|
// 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)));
|
|
}
|
|
|
|
function ldaScoreToScale(ldaScore: number | null): number | null {
|
|
/**
|
|
* Convert LDA score to the analysis[2] "formal vs informal" 1-9 scale.
|
|
* LDA scores typically range from -4 to +4 (8 range); target is 1-9.
|
|
*
|
|
* The model's sign convention (see analysis/bicorder_model.json):
|
|
* positive LDA → cluster 2 = Institutional/Bureaucratic = "formal"
|
|
* negative LDA → cluster 1 = Relational/Cultural = "informal"
|
|
* bicorder.json defines this gradient as 1 = formal, 9 = informal, so a
|
|
* positive LDA score must map toward 1 (formal). The score is therefore
|
|
* subtracted, not added.
|
|
*
|
|
* Formula: value = 5 - (ldaScore * 4/3)
|
|
* - LDA +3 or more → 1 (formal / institutional / bureaucratic)
|
|
* - LDA 0 → 5 (boundary, characteristics of both families)
|
|
* - LDA -3 or less → 9 (informal / relational / cultural)
|
|
*/
|
|
if (ldaScore === null) return null;
|
|
|
|
const value = 5 - (ldaScore * 4.0 / 3.0);
|
|
|
|
// Clamp to 1-9 range and round
|
|
return Math.round(Math.max(1, Math.min(9, value)));
|
|
}
|
|
|
|
function calculateFormalInformal(): number | null {
|
|
// Collect all diagnostic gradients with their set and gradient info
|
|
const ratings: Record<string, number> = {};
|
|
|
|
data.diagnostic.forEach((diagnosticSet) => {
|
|
const setName = diagnosticSet.set_name;
|
|
diagnosticSet.gradients.forEach((gradient) => {
|
|
if (gradient.value !== null) {
|
|
// Dimension name must match the model's keys: SetName_left_vs_right
|
|
const dimensionName = `${setName}_${gradient.term_left}_vs_${gradient.term_right}`;
|
|
ratings[dimensionName] = gradient.value;
|
|
}
|
|
});
|
|
});
|
|
|
|
// Check if we have any ratings
|
|
if (Object.keys(ratings).length === 0) return null;
|
|
|
|
try {
|
|
// Get prediction from classifier (need detailed: true to get ldaScore)
|
|
const result = classifier.predict(ratings, { detailed: true });
|
|
|
|
// Convert LDA score to the 1-9 formal/informal scale
|
|
return ldaScoreToScale(result.ldaScore);
|
|
} catch (error) {
|
|
console.error('Error calculating formal/informal score:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Update automated analysis values reactively
|
|
$: {
|
|
data.analysis.forEach((item, index) => {
|
|
if (item.automated) {
|
|
if (index === 0) {
|
|
// Hardness/Softness
|
|
data.analysis[0].value = calculateHardness();
|
|
} else if (index === 1) {
|
|
// Polarized/Centrist
|
|
data.analysis[1].value = calculatePolarization();
|
|
} else if (index === 2) {
|
|
// Formal/Informal (LDA classifier)
|
|
data.analysis[2].value = calculateFormalInformal();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
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
|
|
recommendation={formRecommendation}
|
|
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>
|