Compare commits

...

6 Commits

Author SHA1 Message Date
Nathan Schneider
b004e9fb19 Analysis changes 2026-01-25 22:55:30 -07:00
Nathan Schneider
900ec1a6af Merge branch 'main' of https://git.medlab.host/ntnsndr/protocol-bicorder 2026-01-25 22:54:32 -07:00
Nathan Schneider
57b780fe95 Additional analysis in app and tweaked json descriptions 2026-01-25 22:48:54 -07:00
6209990436 Updated README ASCII 2026-01-19 22:04:12 +00:00
Nathan Schneider
f53ada8196 User experience improvements to the focused mode 2026-01-18 16:59:56 -07:00
Nathan Schneider
56d1f7e11e Improved analysis screen on focused mode. 2026-01-17 22:58:24 -07:00
7 changed files with 756 additions and 163 deletions

View File

@@ -67,13 +67,13 @@ An example output file from the template is maintained at `bicorder.txt`.
Each gradient is represented this way: Each gradient is represented this way:
``` ```
term_left < [|||||||||] > term_right term_left < [---------] > term_right
``` ```
To mark a gradient in a particular place, it is represented with a `#` like this: To mark a gradient in a particular place, it is represented with a `#` like this:
``` ```
term_left < [||||#||||] > term_right term_left < [----#----] > term_right
``` ```
@@ -89,27 +89,6 @@ The bicorder repository is equipped with a synthetic dataset of protocols, as we
See [`analysis/`](analysis/) for complete documentation and materials. See [`analysis/`](analysis/) for complete documentation and materials.
<!---
### Gradient citations
- implicit / explicit
- See Pomerantz book on Standards, p. 16: de facto and de jure
- Social / technical
- From Primavera's Protocol Art talk
- Kafka / Whitehead (Asparouhva)
- Measuring the extent to which the protocol imposes burdens on users as opposed to freeing them to focus on something else
- flock / swarm (Fernández)
- Measuring the degree of variation as opposed to uniformity the protocol enables
- soft / hard (Stark)
- dead / alive (Friend; Alston et al.; Walch)
- related especially to whether it is actively performed)
- insufficient / sufficient (Kittel & Shorin; Rao et al.)
- Measuring the extent to which the protocol as such solves the problems it is designed to solve, or whether it relies on external mechanisms
- tense / crystallized
- Marc-Antoine Parent countered the idea of "engineered arguments" (which assume ongoing tension) with "crystallized arguments" (which memorialize past tensions that are no longer active). For instance, English is tense, Arabic numerals are crystallized.
--->
## Authorship and licensing ## Authorship and licensing
Initiated by [Nathan Schneider](https://nathanschneider.info) and available for use under the [Hippocratic License](https://firstdonoharm.dev/) (do no harm!). Several AI assistants, local and remote, were utilized in developing this tool. Initiated by [Nathan Schneider](https://nathanschneider.info) and available for use under the [Hippocratic License](https://firstdonoharm.dev/) (do no harm!). Several AI assistants, local and remote, were utilized in developing this tool.

View File

@@ -7,6 +7,8 @@
import ExportControls from './components/ExportControls.svelte'; import ExportControls from './components/ExportControls.svelte';
import HelpModal from './components/HelpModal.svelte'; import HelpModal from './components/HelpModal.svelte';
import FormRecommendation from './components/FormRecommendation.svelte'; import FormRecommendation from './components/FormRecommendation.svelte';
import AnalysisTransitionBanner from './components/AnalysisTransitionBanner.svelte';
import HamburgerMenu from './components/HamburgerMenu.svelte';
import { BicorderClassifier } from './bicorder-classifier'; import { BicorderClassifier } from './bicorder-classifier';
// Load bicorder data and model from build-time constants // Load bicorder data and model from build-time constants
@@ -57,12 +59,12 @@
}); });
}); });
// Analysis screens (not in shortform) // Analysis screens (shown in both shortform and longform)
if (!isShortForm) { // Show useful/not-useful gradient first (index 3), then the others
data.analysis.forEach((gradient, index) => { const analysisOrder = [3, 0, 1, 2]; // useful/not-useful, hardness, polarization, bureaucratic
screens.push({ type: 'analysis', index, gradient }); analysisOrder.forEach((index) => {
}); screens.push({ type: 'analysis', index, gradient: data.analysis[index] });
} });
// Export screen // Export screen
screens.push({ type: 'export' }); screens.push({ type: 'export' });
@@ -96,6 +98,32 @@
viewMode = viewMode === 'focused' ? 'list' : 'focused'; 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 // Generate ASCII progress bar
function generateProgressBar(current: number, total: number): string { function generateProgressBar(current: number, total: number): string {
const filled = '#'; const filled = '#';
@@ -106,7 +134,80 @@
return filled.repeat(filledCount) + empty.repeat(emptyCount); return filled.repeat(filledCount) + empty.repeat(emptyCount);
} }
$: progressBar = generateProgressBar(currentScreen + 1, totalScreens); // 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 // Load saved state from localStorage
onMount(() => { onMount(() => {
@@ -338,7 +439,7 @@
class="toolbar-btn help-btn" class="toolbar-btn help-btn"
on:click={openHelp} on:click={openHelp}
aria-label="About the Bicorder" aria-label="About the Bicorder"
title="Help & Instructions" title="About"
> >
? ?
</button> </button>
@@ -350,20 +451,13 @@
</div> </div>
<div class="header-right"> <div class="header-right">
<FormRecommendation <HamburgerMenu
{classifier} {viewMode}
diagnosticData={data.diagnostic}
isShortForm={data.metadata.shortform} isShortForm={data.metadata.shortform}
on:switchToLongForm={handleSwitchToLongForm} on:toggleViewMode={toggleViewMode}
on:toggleFormMode={toggleFormMode}
on:showAnalysis={showAnalysis}
/> />
<button
class="toolbar-btn mode-toggle"
on:click={toggleViewMode}
aria-label="Toggle view mode"
title={viewMode === 'focused' ? 'Switch to list view' : 'Switch to focused view'}
>
{viewMode === 'focused' ? '☰' : '⊡'}
</button>
</div> </div>
</div> </div>
@@ -480,6 +574,28 @@
<div class="focused-screen gradient-screen"> <div class="focused-screen gradient-screen">
<div class="screen-category">ANALYSIS</div> <div class="screen-category">ANALYSIS</div>
{#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="gradient-focused">
<div class="term-desc left-desc"> <div class="term-desc left-desc">
<div class="term-name">{screen.gradient.term_left}</div> <div class="term-name">{screen.gradient.term_left}</div>
@@ -554,14 +670,10 @@
> >
{progressBar} {progressBar}
</div> </div>
<div class="progress-numbers">{currentScreen + 1} / {totalScreens}</div> <div class="progress-numbers">{diagnosticProgress} / {diagnosticScreenCount}</div>
</div> </div>
</div> </div>
{/if} {/if}
<footer class="footer">
<p>Initiated by <a href="https://nathanschneider.info" rel="nofollow">Nathan Schneider</a>; <a href="https://git.medlab.host/ntnsndr/protocol-bicorder/src/branch/main/bicorder-app" rel="nofollow">source code</a> licensed under the <a href="https://firstdonoharm.dev/" rel="nofollow">Hippocratic License</a> (do no harm!).</p>
</footer>
</main> </main>
<style> <style>
@@ -637,10 +749,6 @@
background-color: var(--input-bg); background-color: var(--input-bg);
} }
.mode-toggle {
font-size: 0.9rem;
}
.description { .description {
text-align: center; text-align: center;
padding: 1rem; padding: 1rem;
@@ -655,19 +763,6 @@
margin: 0; margin: 0;
} }
.footer {
margin-top: auto;
padding: 2rem 1rem 1rem;
border-top: 2px solid var(--border-color);
text-align: center;
font-size: 0.85rem;
opacity: 0.7;
}
.footer p {
margin: 0.5rem 0;
}
.diagnostic-set, .analysis-section { .diagnostic-set, .analysis-section {
margin: 2rem 0; margin: 2rem 0;
padding: 1rem 0; padding: 1rem 0;
@@ -697,6 +792,7 @@
justify-content: center; justify-content: center;
min-height: 400px; min-height: 400px;
padding: 2rem 0; padding: 2rem 0;
padding-bottom: 180px; /* Space for fixed navigation */
} }
.focused-screen { .focused-screen {
@@ -763,16 +859,22 @@
} }
.focused-navigation { .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; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5rem; gap: 1rem;
padding: 1.5rem 0; z-index: 100;
margin-top: auto; box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
border-top: 2px solid var(--border-color);
} }
.focused-navigation.no-border { .focused-navigation.no-border {
border-top: none; border-top: 2px solid var(--border-color);
} }
.nav-buttons { .nav-buttons {
@@ -780,12 +882,15 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
max-width: 800px;
margin: 0 auto;
width: 100%;
} }
.nav-btn { .nav-btn {
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
font-size: 1rem; font-size: 1rem;
min-width: 120px; flex: 1;
} }
.nav-btn:disabled { .nav-btn:disabled {
@@ -799,6 +904,9 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
max-width: 800px;
margin: 0 auto;
width: 100%;
} }
.progress-bar { .progress-bar {
@@ -861,32 +969,15 @@
font-size: 0.9rem; font-size: 0.9rem;
} }
.mode-toggle {
font-size: 0.75rem;
}
.description { .description {
padding: 0.75rem; padding: 0.75rem;
font-size: 0.85rem; font-size: 0.85rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.footer {
padding: 1.5rem 0.5rem 0.5rem;
font-size: 0.8rem;
}
.progress-bar {
font-size: 0.85rem;
letter-spacing: 0.05rem;
}
.progress-numbers {
font-size: 0.8rem;
}
.focused-container { .focused-container {
padding: 1rem 0; padding: 1rem 0;
padding-bottom: 160px; /* Adjust for smaller mobile nav */
} }
.gradient-focused { .gradient-focused {
@@ -894,10 +985,22 @@
gap: 1.5rem; gap: 1.5rem;
} }
.focused-navigation {
padding: 0.75rem;
}
.nav-btn { .nav-btn {
min-width: 80px; flex: 1;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
font-size: 0.9rem; font-size: 0.9rem;
} }
.progress-bar {
font-size: 0.85rem;
}
.progress-numbers {
font-size: 0.8rem;
}
} }
</style> </style>

View File

@@ -0,0 +1,359 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import AnalysisDisplay from './AnalysisDisplay.svelte';
import type { AnalysisGradient } from '../types';
export let recommendation: any = null;
export let isShortForm: boolean;
export let completedGradients: number;
export let allAnalysisGradients: AnalysisGradient[];
const dispatch = createEventDispatcher<{
switchToLongForm: void;
jumpToExport: void;
updateAnalysis: { index: number; value: number | null };
updateAnalysisNotes: { index: number; notes: string };
}>();
$: hasRecommendation = recommendation?.recommendedForm === 'long';
let showAllAnalysis = false;
function handleSwitchToLongForm() {
dispatch('switchToLongForm');
}
function handleJumpToExport() {
dispatch('jumpToExport');
}
function toggleAllAnalysis() {
showAllAnalysis = !showAllAnalysis;
}
</script>
<div class="transition-banner">
<div class="banner-content">
<div class="banner-header">
<div class="completion-icon"></div>
<div class="header-text">
<h3>Diagnostics Complete</h3>
<p class="subtext">Analysis calculated from {completedGradients} gradient{completedGradients !== 1 ? 's' : ''}</p>
</div>
</div>
<!-- Recommendation Alert (if applicable) -->
{#if isShortForm && hasRecommendation && recommendation}
<div class="recommendation-alert">
<div class="alert-header">
<span class="alert-icon"></span>
<strong>Long Form Recommended</strong>
</div>
<div class="alert-body">
<p class="alert-message">
{#if recommendation.confidence < 60}
• Low classification confidence ({recommendation.confidence}%)<br>
{/if}
{#if recommendation.completeness < 50}
• Incomplete data ({recommendation.completeness}% of dimensions)<br>
{/if}
{#if recommendation.distanceToBoundary < 0.5}
• Protocol near boundary between families<br>
{/if}
{#if recommendation.coverage < 75}
• Missing key dimensions for reliable short-form classification ({recommendation.coverage}% coverage)<br>
{/if}
</p>
</div>
</div>
{/if}
<!-- Action Buttons -->
<div class="action-buttons">
<button class="action-btn view-analysis-btn" on:click={toggleAllAnalysis}>
{showAllAnalysis ? '▼' : '▶'} {showAllAnalysis ? 'Hide' : 'View'} Analysis
</button>
<button class="action-btn export-btn" on:click={handleJumpToExport}>
Export Readings →
</button>
{#if isShortForm}
<button class="action-btn longform-btn" on:click={handleSwitchToLongForm}>
Switch to Long Form & Restart →
</button>
{/if}
</div>
<!-- All Analysis Gradients (expandable) -->
{#if showAllAnalysis}
<div class="all-analysis-section">
<div class="analysis-header">Analysis Gradients</div>
{#each allAnalysisGradients as gradient, index}
{#if index !== 3}
<div class="analysis-item">
<AnalysisDisplay
{gradient}
focusedMode={false}
on:change={(e) => dispatch('updateAnalysis', { index, value: e.detail })}
on:notes={(e) => dispatch('updateAnalysisNotes', { index, notes: e.detail })}
/>
</div>
{/if}
{/each}
</div>
{/if}
</div>
</div>
<style>
.transition-banner {
margin-bottom: 2rem;
animation: fadeInSlide 0.5s ease-out;
}
@keyframes fadeInSlide {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.banner-content {
border: 2px solid var(--border-color);
background: var(--input-bg);
padding: 1.5rem;
position: relative;
overflow: hidden;
}
.banner-content::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg,
transparent 0%,
var(--fg-color) 20%,
var(--fg-color) 80%,
transparent 100%);
opacity: 0.3;
animation: shimmer 2s ease-in-out;
}
@keyframes shimmer {
0% {
opacity: 0;
transform: translateX(-100%);
}
50% {
opacity: 0.3;
}
100% {
opacity: 0;
transform: translateX(100%);
}
}
.banner-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
}
.completion-icon {
font-size: 2rem;
color: #4ade80;
animation: checkPop 0.5s ease-out 0.2s both;
}
@keyframes checkPop {
0% {
transform: scale(0);
opacity: 0;
}
50% {
transform: scale(1.2);
}
100% {
transform: scale(1);
opacity: 1;
}
}
.header-text h3 {
margin: 0;
font-size: 1.1rem;
font-weight: bold;
letter-spacing: 0.05rem;
}
.subtext {
margin: 0.25rem 0 0 0;
font-size: 0.85rem;
opacity: 0.7;
}
.recommendation-alert {
margin-top: 1rem;
padding: 1rem;
background: rgba(251, 191, 36, 0.1);
border: 2px solid #fbbf24;
border-radius: 4px;
}
.alert-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.alert-icon {
font-size: 1.2rem;
color: #fbbf24;
}
.alert-header strong {
font-size: 1rem;
color: #fbbf24;
}
.alert-body {
padding-left: 1.7rem;
}
.alert-message {
margin: 0;
font-size: 0.9rem;
line-height: 1.5;
}
.action-buttons {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 1.5rem;
}
.action-btn {
width: 100%;
padding: 0.75rem;
font-size: 0.95rem;
font-weight: bold;
border: 2px solid var(--border-color);
cursor: pointer;
transition: all 0.2s;
border-radius: 3px;
background: var(--bg-color);
color: var(--fg-color);
}
.action-btn:hover {
transform: translateY(-1px);
border-color: var(--fg-color);
}
.export-btn {
background: var(--fg-color);
color: var(--bg-color);
border-color: var(--fg-color);
}
.export-btn:hover {
opacity: 0.9;
}
.longform-btn {
background: #fbbf24;
color: #1a1a2e;
border-color: #fbbf24;
}
.longform-btn:hover {
background: #f59e0b;
border-color: #f59e0b;
box-shadow: 0 2px 8px rgba(251, 191, 36, 0.3);
}
.view-analysis-btn {
text-align: left;
}
.all-analysis-section {
margin-top: 1.5rem;
padding: 1rem;
border: 1px solid var(--border-color);
background: var(--bg-color);
animation: slideDown 0.3s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
max-height: 0;
padding: 0 1rem;
}
to {
opacity: 1;
max-height: 2000px;
padding: 1rem;
}
}
.analysis-header {
font-size: 1rem;
font-weight: bold;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color);
}
.analysis-item {
margin-bottom: 1rem;
}
.analysis-item:last-child {
margin-bottom: 0;
}
@media (max-width: 768px) {
.banner-content {
padding: 1rem;
}
.banner-header {
gap: 0.75rem;
}
.completion-icon {
font-size: 1.5rem;
}
.header-text h3 {
font-size: 1rem;
}
.subtext {
font-size: 0.8rem;
}
.alert-body {
padding-left: 1rem;
}
.action-btn {
font-size: 0.9rem;
padding: 0.6rem;
}
.all-analysis-section {
padding: 0.75rem;
}
}
</style>

View File

@@ -1,9 +1,8 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import type { BicorderClassifier } from '../bicorder-classifier';
export let classifier: BicorderClassifier; export let recommendation: any = null;
export let diagnosticData: any; export let hasEnoughData: boolean;
export let isShortForm: boolean; export let isShortForm: boolean;
const dispatch = createEventDispatcher<{ const dispatch = createEventDispatcher<{
@@ -11,56 +10,6 @@
}>(); }>();
let isExpanded = false; let isExpanded = false;
let recommendation: any = null;
let hasEnoughData = false;
// Calculate recommendation based on current diagnostic data
$: {
// Collect ratings from diagnostic data
const ratings: Record<string, number> = {};
let valueCount = 0;
let shortFormTotal = 0;
diagnosticData.forEach((diagnosticSet: any) => {
const setName = diagnosticSet.set_name;
diagnosticSet.gradients.forEach((gradient: any) => {
// 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 show if at least half of shortform gradients are complete
const threshold = Math.ceil(shortFormTotal / 2);
hasEnoughData = valueCount >= threshold;
if (hasEnoughData && isShortForm) {
try {
const prediction = classifier.predict(ratings, { detailed: true });
const assessment = classifier.assessShortFormReadiness(ratings);
recommendation = {
...prediction,
...assessment,
};
} catch (error) {
console.error('Error getting form recommendation:', error);
recommendation = null;
}
} else {
recommendation = null;
}
}
function toggleExpanded() { function toggleExpanded() {
isExpanded = !isExpanded; isExpanded = !isExpanded;
@@ -147,9 +96,9 @@
{/if} {/if}
</p> </p>
<button class="switch-btn" on:click={handleSwitchToLongForm}> <button class="switch-btn" on:click={handleSwitchToLongForm}>
Switch to Long Form → Switch to Long Form & Restart
</button> </button>
<p class="note">All your current values will be preserved.</p> <p class="note">Returns to the beginning. All your current values will be preserved.</p>
</div> </div>
{:else} {:else}
<div class="recommendation-message good"> <div class="recommendation-message good">

View File

@@ -0,0 +1,186 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
export let viewMode: 'focused' | 'list' = 'focused';
export let isShortForm: boolean = true;
const dispatch = createEventDispatcher();
let isOpen = false;
function toggleMenu() {
isOpen = !isOpen;
}
function closeMenu() {
isOpen = false;
}
function handleToggleViewMode() {
dispatch('toggleViewMode');
closeMenu();
}
function handleToggleFormMode() {
dispatch('toggleFormMode');
closeMenu();
}
function handleShowAnalysis() {
dispatch('showAnalysis');
closeMenu();
}
// Close menu when clicking outside
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.hamburger-menu')) {
closeMenu();
}
}
</script>
<svelte:window on:click={handleClickOutside} />
<div class="hamburger-menu">
<button
class="toolbar-btn hamburger-btn"
on:click|stopPropagation={toggleMenu}
aria-label="Menu"
title="Menu"
>
</button>
{#if isOpen}
<div class="menu-dropdown">
<button class="menu-item" on:click={handleToggleViewMode}>
<span class="menu-icon">{viewMode === 'focused' ? '☰' : '⊡'}</span>
<span class="menu-text">
{viewMode === 'focused' ? 'List mode' : 'Focused mode'}
</span>
</button>
<button class="menu-item" on:click={handleToggleFormMode}>
<span class="menu-icon">{isShortForm ? '📋' : '📄'}</span>
<span class="menu-text">
{isShortForm ? 'Long form' : 'Short form'}
</span>
</button>
<button class="menu-item" on:click={handleShowAnalysis}>
<span class="menu-icon">📊</span>
<span class="menu-text">Show analysis</span>
</button>
</div>
{/if}
</div>
<style>
.hamburger-menu {
position: relative;
}
.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:hover {
opacity: 0.8;
background-color: var(--input-bg);
}
.hamburger-btn {
font-size: 0.9rem;
}
.menu-dropdown {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 3px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 200px;
z-index: 1000;
overflow: hidden;
}
.menu-item {
width: 100%;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border: none;
background: var(--bg-color);
color: var(--fg-color);
cursor: pointer;
font-size: 0.9rem;
transition: background-color 0.2s;
text-align: left;
}
.menu-item:hover {
background-color: var(--input-bg);
}
.menu-item:not(:last-child) {
border-bottom: 1px solid var(--border-color);
}
.menu-icon {
font-size: 1rem;
min-width: 1.2rem;
display: flex;
align-items: center;
justify-content: center;
}
.menu-text {
flex: 1;
}
@media (max-width: 768px) {
.toolbar-btn {
width: 1.5rem;
height: 1.5rem;
font-size: 0.85rem;
}
.hamburger-btn {
font-size: 0.75rem;
}
.menu-dropdown {
min-width: 180px;
}
.menu-item {
padding: 0.6rem 0.85rem;
font-size: 0.85rem;
}
.menu-icon {
font-size: 0.9rem;
min-width: 1rem;
}
}
</style>

View File

@@ -18,19 +18,22 @@
<div class="modal-backdrop" on:click={closeModal} on:keydown={() => {}} role="button" tabindex="-1"> <div class="modal-backdrop" on:click={closeModal} on:keydown={() => {}} role="button" tabindex="-1">
<div class="modal-content" on:click|stopPropagation on:keydown={() => {}} role="dialog" aria-modal="true"> <div class="modal-content" on:click|stopPropagation on:keydown={() => {}} role="dialog" aria-modal="true">
<div class="modal-header"> <div class="modal-header">
<h2>Help & Instructions</h2> <h2>About</h2>
<button class="close-btn" on:click={closeModal} aria-label="Close help">×</button> <button class="close-btn" on:click={closeModal} aria-label="Close">×</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p>The Protocol Bicorder is a diagnostic tool for the study of protocols. It allows a human or machine user to evaluate protocol characteristics along a series of gradients between opposing terms.</p> <p>The Protocol Bicorder is a diagnostic tool for the study of protocols. It allows a human or machine user to evaluate protocol characteristics along a series of gradients between opposing terms.</p>
<p>The name is a tribute to the tricorder, a fictional device in the Star Trek universe that the characters can use to obtain all manner of empirical data about their surroundings.</p> <p>The name is a tribute to the tricorder, a fictional device in the Star Trek universe that the characters can use to obtain all manner of empirical data about their surroundings.</p>
<p>To carry out the diagnostic, the analyst should consider the protocol from the perspective of one of the <code>gradients</code> at a time. The gradients invite the analyst to determine where the protocol lies between two terms.</p> <p>To carry out the diagnostic, consider the protocol from the perspective of one gradient at a time. Determine where the protocol lies between two terms, with <code>1</code> closest to <code>term_left</code> and <code>9</code> closest to <code>term_right</code>.</p>
<p>This is inevitably an interpretive exercise, but do your best to identify the most accurate <code>value</code>, with <code>1</code> being closest to <code>term_left</code> and <code>9</code> being closest to <code>term_right</code>.</p> <p>A middle <code>value</code> like <code>5</code> means "a bit of both." Leaving the gradient <code>value</code> as <code>null</code> means "not applicable."</p>
<p>Choosing a <code>value</code> in the middle, such as <code>5</code>, can mean "a bit of both." Leaving the gradient <code>value</code> as <code>null</code> means "not applicable."</p> <p>Use the <code>notes</code> field to add context or explanation.</p>
<p>There is a <code>notes</code> field for the analyst to add additional context or explanation.</p> <p>Use the menu (☰) to toggle between focused/list modes and short/long forms. Short form includes only the most salient gradients.</p>
<p>The <code>shortform</code> option allows the use of an abbreviated version of the bicorder, including only the most salient gradients.</p>
<p>Happy protocol watching!</p> <p>Happy protocol watching!</p>
<hr class="divider" />
<p class="credits">Initiated by <a href="https://nathanschneider.info" rel="nofollow">Nathan Schneider</a>; <a href="https://git.medlab.host/ntnsndr/protocol-bicorder/src/branch/main/bicorder-app" rel="nofollow">source code</a> licensed under the <a href="https://firstdonoharm.dev/" rel="nofollow">Hippocratic License</a> (do no harm!).</p>
</div> </div>
</div> </div>
</div> </div>
@@ -126,6 +129,20 @@
color: var(--accent-color); color: var(--accent-color);
} }
.divider {
margin: 2rem 0 1.5rem 0;
border: none;
border-top: 1px solid var(--border-color);
opacity: 0.5;
}
.credits {
font-size: 0.85rem;
opacity: 0.7;
text-align: center;
line-height: 1.6;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.modal-content { .modal-content {
max-height: 90vh; max-height: 90vh;

View File

@@ -1,7 +1,7 @@
{ {
"name": "Protocol Bicorder", "name": "Protocol Bicorder",
"schema": "bicorder.schema.json", "schema": "bicorder.schema.json",
"version": "1.2.4", "version": "1.2.5",
"description": "A diagnostic tool for the study of protocols", "description": "A diagnostic tool for the study of protocols",
"author": "Nathan Schneider", "author": "Nathan Schneider",
"date_modified": "2025-12-02", "date_modified": "2025-12-02",
@@ -22,18 +22,18 @@
"gradients": [ "gradients": [
{ {
"term_left": "explicit", "term_left": "explicit",
"term_left_description": "The design is stated explicitly somewhere that is accessible to participants", "term_left_description": "Design is stated explicitly somewhere that is accessible to participants",
"term_right": "implicit", "term_right": "implicit",
"term_right_description": "The design is not stated explicitly and is learned by use", "term_right_description": "Design is not stated explicitly and is learned by use",
"value": null, "value": null,
"notes": null, "notes": null,
"shortform": false "shortform": false
}, },
{ {
"term_left": "precise", "term_left": "precise",
"term_left_description": "The design is specified with a high level of precision that eliminates ambiguity in implementation", "term_left_description": "Specified with a high level of precision that eliminates ambiguity in implementation",
"term_right": "interpretive", "term_right": "interpretive",
"term_right_description": "The design is ambiguous, allowing participants a wide range of interpretation", "term_right_description": "Ambiguous design, allowing participants a wide range of interpretation",
"value": null, "value": null,
"notes": null, "notes": null,
"shortform": true "shortform": true
@@ -49,9 +49,9 @@
}, },
{ {
"term_left": "documenting", "term_left": "documenting",
"term_left_description": "The primary purpose is to document or validate activity that is occurring", "term_left_description": "Intended to document or validate activity that is occurring",
"term_right": "enabling", "term_right": "enabling",
"term_right_description": "The primary purpose is to enable activity that might not happen otherwise", "term_right_description": "Intended to enable activity that might not happen otherwise",
"value": null, "value": null,
"notes": null, "notes": null,
"shortform": false "shortform": false
@@ -118,7 +118,7 @@
}, },
{ {
"term_left": "self-enforcing", "term_left": "self-enforcing",
"term_left_description": "Rules are automatically enforced through its own mechanisms", "term_left_description": "Rules automatically enforced through its own mechanisms",
"term_right": "enforced", "term_right": "enforced",
"term_right_description": "Rules require external enforcement by authorities or institutions", "term_right_description": "Rules require external enforcement by authorities or institutions",
"value": null, "value": null,
@@ -136,9 +136,9 @@
}, },
{ {
"term_left": "obligatory", "term_left": "obligatory",
"term_left_description": "Participation is compulsory for a certain class of agents", "term_left_description": "Compulsory participation for a certain class of agents",
"term_right": "voluntary", "term_right": "voluntary",
"term_right_description": "Participation in the protocol is optional and not coerced", "term_right_description": "Participation is optional and not coerced",
"value": null, "value": null,
"notes": null, "notes": null,
"shortform": true "shortform": true