First commit for bicorder-app

This commit is contained in:
Nathan Schneider
2025-11-25 13:20:21 -05:00
parent 3a55d3dbb9
commit b541f6049e
24 changed files with 8883 additions and 0 deletions

227
bicorder-app/src/App.svelte Normal file
View File

@@ -0,0 +1,227 @@
<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';
// Load bicorder data from build-time constant
let data: BicorderState = JSON.parse(JSON.stringify(__BICORDER_DATA__));
// Initialize timestamp if not set
if (!data.metadata.timestamp) {
data.metadata.timestamp = new Date().toISOString();
}
// 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);
}
}
});
// 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;
return Math.round(polarizationScore);
}
// Update automated analysis values reactively
$: {
data.analysis.forEach((item, index) => {
if (item.automated) {
if (index === 0) {
data.analysis[0].value = calculateHardness();
} else if (index === 1) {
data.analysis[1].value = calculatePolarization();
}
}
});
}
function handleMetadataUpdate(event: CustomEvent) {
data.metadata = { ...data.metadata, ...event.detail };
}
function handleReset() {
if (confirm('Reset all values? This cannot be undone.')) {
localStorage.removeItem('bicorder-state');
location.reload();
}
}
</script>
<main>
<div class="header">
<div class="title">Protocol</div>
<div class="title">BICORDER</div>
</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} />
</main>
<style>
main {
max-width: 800px;
margin: 0 auto;
padding: 1rem;
}
.header {
text-align: center;
margin-bottom: 2rem;
border-bottom: 2px solid var(--border-color);
padding-bottom: 1rem;
}
.title {
font-size: 1.5rem;
font-weight: bold;
letter-spacing: 0.2rem;
}
.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;
}
@media (max-width: 768px) {
main {
padding: 0.5rem;
}
.title {
font-size: 1.2rem;
}
.set-header {
font-size: 1rem;
}
}
</style>

111
bicorder-app/src/app.css Normal file
View File

@@ -0,0 +1,111 @@
:root {
color-scheme: light dark;
font-family: 'Courier New', Courier, monospace;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
/* Light mode (default) */
@media (prefers-color-scheme: light) {
:root {
--bg-color: #ffffff;
--fg-color: #000000;
--border-color: #000000;
--hover-color: #333333;
--disabled-color: #cccccc;
--input-bg: #f5f5f5;
}
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
:root {
--bg-color: #000000;
--fg-color: #ffffff;
--border-color: #ffffff;
--hover-color: #cccccc;
--disabled-color: #666666;
--input-bg: #1a1a1a;
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: var(--bg-color);
color: var(--fg-color);
min-height: 100vh;
touch-action: manipulation;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
#app {
width: 100%;
min-height: 100vh;
padding: 1rem;
}
input, textarea {
font-family: 'Courier New', Courier, monospace;
font-size: 1rem;
background-color: var(--input-bg);
color: var(--fg-color);
border: 1px solid var(--border-color);
padding: 0.5rem;
width: 100%;
outline: none;
}
input:focus, textarea:focus {
border-color: var(--hover-color);
box-shadow: 0 0 3px var(--hover-color);
}
button {
font-family: 'Courier New', Courier, monospace;
font-size: 1rem;
background-color: var(--bg-color);
color: var(--fg-color);
border: 2px solid var(--border-color);
padding: 0.5rem 1rem;
cursor: pointer;
touch-action: manipulation;
user-select: none;
min-height: 44px;
min-width: 44px;
}
button:hover:not(:disabled), button:active:not(:disabled) {
background-color: var(--fg-color);
color: var(--bg-color);
border-color: var(--fg-color);
}
button:disabled {
border-color: var(--disabled-color);
color: var(--disabled-color);
cursor: not-allowed;
}
button:disabled:hover {
background-color: var(--bg-color);
color: var(--disabled-color);
}
@media (max-width: 768px) {
body {
font-size: 14px;
}
input, textarea, button {
font-size: 16px; /* Prevent iOS zoom on focus */
}
}

View File

@@ -0,0 +1,368 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { AnalysisGradient } from '../types';
import Tooltip from './Tooltip.svelte';
export let gradient: AnalysisGradient;
const dispatch = createEventDispatcher<{
change: number | null;
notes: string;
}>();
let showNotes = false;
let notesText = gradient.notes || '';
function handleNotApplicable() {
dispatch('change', null);
}
function handleNotesSave() {
dispatch('notes', notesText);
showNotes = false;
}
function handleScaleClick(event: MouseEvent) {
if (gradient.automated) return;
const target = event.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const x = event.clientX - rect.left;
const width = rect.width;
// Account for button padding to get actual text area
const styles = window.getComputedStyle(target);
const paddingLeft = parseFloat(styles.paddingLeft);
const paddingRight = parseFloat(styles.paddingRight);
const contentWidth = width - paddingLeft - paddingRight;
const xInContent = x - paddingLeft;
// Clamp to content area and get ratio
const ratio = Math.max(0, Math.min(1, xInContent / contentWidth));
// Map to 1-9 with proper centering on each character position
const value = Math.min(9, Math.max(1, Math.floor(ratio * 9) + 1));
dispatch('change', value);
}
function handleScaleTouch(event: TouchEvent) {
if (gradient.automated) return;
event.preventDefault();
const target = event.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const touch = event.touches[0];
const x = touch.clientX - rect.left;
const width = rect.width;
// Account for button padding to get actual text area
const styles = window.getComputedStyle(target);
const paddingLeft = parseFloat(styles.paddingLeft);
const paddingRight = parseFloat(styles.paddingRight);
const contentWidth = width - paddingLeft - paddingRight;
const xInContent = x - paddingLeft;
// Clamp to content area and get ratio
const ratio = Math.max(0, Math.min(1, xInContent / contentWidth));
// Map to 1-9 with proper centering on each character position
const value = Math.min(9, Math.max(1, Math.floor(ratio * 9) + 1));
dispatch('change', value);
}
function renderBar(value: number | null): string {
// Fixed scale with 9 positions using ||||#||||
if (value === null) {
return '||||·||||';
}
// Value is 1-9, position the # marker at the right spot
const positions = [
'#||||||||',
'|#|||||||',
'||#||||||',
'|||#|||||',
'||||#||||',
'|||||#|||',
'||||||#||',
'|||||||#|',
'||||||||#'
];
return positions[value - 1];
}
$: barDisplay = renderBar(gradient.value);
</script>
<div class="analysis-gradient">
<div class="gradient-row">
<div class="term left">
<Tooltip text={gradient.term_left_description}>
{gradient.term_left}
</Tooltip>
</div>
<div class="bar-container">
<button
class="bar"
class:interactive={!gradient.automated}
class:automated={gradient.automated}
on:click={handleScaleClick}
on:touchstart={handleScaleTouch}
role={gradient.automated ? 'img' : 'slider'}
aria-label={gradient.automated ? `Analysis showing value ${gradient.value || 'not calculated'}` : `Gradient scale between ${gradient.term_left} and ${gradient.term_right}`}
aria-valuemin={gradient.automated ? undefined : 1}
aria-valuemax={gradient.automated ? undefined : 9}
aria-valuenow={gradient.automated ? undefined : gradient.value}
title={gradient.automated ? 'Auto-calculated' : 'Click or tap on the scale to set value'}
disabled={gradient.automated}
>
{barDisplay}
</button>
</div>
<div class="term right">
<Tooltip text={gradient.term_right_description}>
{gradient.term_right}
</Tooltip>
</div>
</div>
<div class="value-display">
Value: {gradient.value !== null ? gradient.value : ''}
{#if gradient.automated}
<span class="auto-label">(auto-calculated)</span>
{/if}
</div>
<div class="controls">
{#if !gradient.automated}
<div class="button-row">
<button
class="na-btn"
class:active={gradient.value === null}
on:click={handleNotApplicable}
aria-label="Mark as not applicable"
title="Mark this gradient as not applicable"
>
N/A
</button>
<button
class="notes-btn"
class:has-notes={gradient.notes}
on:click={() => showNotes = !showNotes}
aria-label="Add notes"
>
{gradient.notes ? '📝' : ''} Notes
</button>
</div>
{:else}
<button
class="notes-btn"
class:has-notes={gradient.notes}
on:click={() => showNotes = !showNotes}
aria-label="Add notes"
>
{gradient.notes ? '📝' : ''} Notes
</button>
{/if}
</div>
{#if showNotes}
<div class="notes-editor">
<textarea
bind:value={notesText}
placeholder="Add notes or reference..."
rows="2"
/>
<div class="notes-buttons">
<button on:click={handleNotesSave}>Save</button>
<button on:click={() => showNotes = false}>Cancel</button>
</div>
</div>
{/if}
</div>
<style>
.analysis-gradient {
margin: 1rem 0;
padding: 0.5rem;
}
.gradient-row {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
}
.term {
text-align: center;
font-size: 0.9rem;
}
.term.left {
text-align: right;
}
.term.right {
text-align: left;
}
.bar-container {
display: flex;
justify-content: center;
}
.bar {
font-family: 'Courier New', Courier, monospace;
font-size: 1.8rem;
white-space: nowrap;
letter-spacing: 0.2rem;
padding: 0.5rem 1rem;
user-select: none;
min-width: 240px;
text-align: center;
background-color: var(--bg-color);
color: var(--fg-color);
border: 2px solid var(--border-color);
}
.bar.automated {
cursor: default;
opacity: 0.6;
}
.bar.interactive {
cursor: pointer;
transition: border-color 0.2s;
}
.bar.interactive:hover {
border-color: var(--hover-color);
}
.bar.interactive:active {
background-color: var(--input-bg);
border-color: var(--fg-color);
}
.value-display {
text-align: center;
margin: 0.5rem 0;
font-size: 0.9rem;
}
.auto-label {
opacity: 0.6;
font-size: 0.8rem;
font-style: italic;
}
.controls {
display: flex;
justify-content: center;
margin-top: 0.5rem;
}
.button-row {
display: flex;
gap: 0.5rem;
width: auto;
}
.na-btn {
padding: 0.25rem 0.75rem;
font-size: 0.85rem;
opacity: 0.6;
flex-shrink: 0;
min-height: auto;
width: auto;
}
.na-btn:hover {
opacity: 1;
background-color: transparent;
color: var(--fg-color);
border-color: var(--hover-color);
}
.na-btn.active {
opacity: 1;
background-color: var(--input-bg);
border-color: var(--fg-color);
}
.notes-btn {
padding: 0.25rem 0.75rem;
font-size: 0.85rem;
opacity: 0.6;
min-height: auto;
width: auto;
}
.notes-btn:hover {
opacity: 1;
background-color: transparent;
color: var(--fg-color);
border-color: var(--hover-color);
}
.notes-btn.has-notes {
opacity: 1;
border-color: var(--hover-color);
}
.notes-btn.has-notes:hover {
background-color: transparent;
color: var(--fg-color);
}
.notes-editor {
margin-top: 0.5rem;
padding: 0.5rem;
border: 1px solid var(--border-color);
}
.notes-buttons {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.notes-buttons button {
flex: 1;
}
@media (max-width: 768px) {
.gradient-row {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
gap: 0.5rem;
}
.term.left {
text-align: left;
grid-row: 1;
}
.bar-container {
grid-row: 2;
}
.term.right {
text-align: right;
grid-row: 3;
}
.bar {
font-size: 1.4rem;
min-width: 200px;
padding: 0.5rem;
}
}
</style>

View File

@@ -0,0 +1,233 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { BicorderState } from '../types';
export let data: BicorderState;
const dispatch = createEventDispatcher<{
reset: void;
}>();
let showUploadDialog = false;
let isUploading = false;
// Gitea configuration
const GITEA_TOKEN = 'd495e72e955c00be2de0f1e18183f6a385b6e52c';
const GITEA_API_URL = 'https://git.medlab.host/api/v1';
const REPO_OWNER = 'ntnsndr';
const REPO_NAME = 'protocol-bicorder-data';
const REPO_URL = `https://git.medlab.host/${REPO_OWNER}/${REPO_NAME}`;
const APP_VERSION = '1.0.0';
function exportToJSON() {
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `bicorder-${data.metadata.protocol || 'diagnostic'}-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
async function shareResults() {
const json = JSON.stringify(data, null, 2);
const file = new File([json], `bicorder-${data.metadata.protocol || 'diagnostic'}.json`, {
type: 'application/json',
});
if (navigator.share && navigator.canShare && navigator.canShare({ files: [file] })) {
try {
await navigator.share({
title: 'Protocol Bicorder Diagnostic',
text: `Diagnostic for: ${data.metadata.protocol || 'Protocol'}`,
files: [file],
});
} catch (err) {
if ((err as Error).name !== 'AbortError') {
console.error('Share failed:', err);
alert('Share failed. Try using the Export button instead.');
}
}
} else {
alert('Web Share API not supported. Use the Export button to download the file.');
}
}
async function uploadReadings() {
isUploading = true;
try {
// Generate filename with timestamp
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5) + 'Z';
const filename = `bicorder-${timestamp}.json`;
const filepath = `readings/${filename}`;
// Create commit message
const protocolName = data.metadata.protocol || 'Unknown Protocol';
const analystName = data.metadata.analyst || 'Anonymous';
const commitMessage = `Bicorder reading: ${protocolName} by ${analystName} | Source: Protocol Bicorder v${APP_VERSION}`;
// Prepare the content (base64 encoded)
const jsonContent = JSON.stringify(data, null, 2);
const base64Content = btoa(unescape(encodeURIComponent(jsonContent)));
// Upload to Gitea
const response = await fetch(
`${GITEA_API_URL}/repos/${REPO_OWNER}/${REPO_NAME}/contents/${filepath}`,
{
method: 'POST',
mode: 'cors',
headers: {
'Authorization': `token ${GITEA_TOKEN}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
content: base64Content,
message: commitMessage,
branch: 'main',
}),
}
);
if (response.ok) {
alert('Successfully uploaded! Your reading is now public.');
showUploadDialog = false;
} else {
const errorData = await response.json();
throw new Error(errorData.message || `Upload failed: ${response.statusContents}`);
}
} catch (err) {
console.error('Upload error:', err);
alert(`Upload error: ${(err as Error).message}`);
} finally {
isUploading = false;
}
}
function handleReset() {
dispatch('reset');
}
</script>
<section class="export-controls">
<div class="button-group">
<button on:click={exportToJSON}>
💾 Export JSON
</button>
{#if navigator.share}
<button on:click={shareResults}>
📤 Share
</button>
{/if}
<button on:click={() => showUploadDialog = !showUploadDialog}>
📤 Upload
</button>
<button class="reset-btn" on:click={handleReset}>
🗑️ Reset All
</button>
</div>
{#if showUploadDialog}
<div class="webhook-config">
<p class="upload-confirmation">
Are you sure you are ready to share your readings publicly?
</p>
<p class="upload-terms">
Submitted readings are posted publicly and licensed to the public domain. By proceeding, you agree to these terms.
</p>
<p class="upload-repo-link">
Readings are posted to <a href={REPO_URL} target="_blank" rel="noopener noreferrer">this repository</a>.
</p>
<div class="webhook-buttons">
<button on:click={uploadReadings} disabled={isUploading}>
{isUploading ? 'Uploading...' : 'Proceed'}
</button>
<button on:click={() => showUploadDialog = false} disabled={isUploading}>Cancel</button>
</div>
</div>
{/if}
</section>
<style>
.export-controls {
margin: 2rem 0;
padding: 1rem;
border-top: 2px solid var(--border-color);
}
.button-group {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.5rem;
margin-bottom: 1rem;
}
.reset-btn {
border-color: #ff0000;
color: #ff0000;
}
.reset-btn:hover {
background-color: #ff0000;
color: #ffffff;
}
.webhook-config {
margin-top: 1rem;
padding: 1rem;
border: 2px solid var(--border-color);
}
.upload-confirmation {
font-size: 1rem;
font-weight: bold;
margin-bottom: 1rem;
text-align: center;
}
.upload-terms {
font-size: 0.85rem;
opacity: 0.7;
line-height: 1.4;
margin-bottom: 1rem;
text-align: center;
}
.upload-repo-link {
font-size: 0.9rem;
margin-bottom: 1rem;
text-align: center;
}
.upload-repo-link a {
color: var(--fg-color);
text-decoration: underline;
}
.upload-repo-link a:hover {
opacity: 0.7;
}
.webhook-buttons {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.webhook-buttons button {
flex: 1;
}
@media (max-width: 768px) {
.button-group {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,336 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { Gradient } from '../types';
import Tooltip from './Tooltip.svelte';
export let gradient: Gradient;
const dispatch = createEventDispatcher<{
change: number;
notes: string;
}>();
let showNotes = false;
let notesText = gradient.notes || '';
function handleNotApplicable() {
dispatch('change', null);
}
function handleNotesSave() {
dispatch('notes', notesText);
showNotes = false;
}
function handleScaleClick(event: MouseEvent) {
const target = event.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const x = event.clientX - rect.left;
const width = rect.width;
// Account for button padding to get actual text area
const styles = window.getComputedStyle(target);
const paddingLeft = parseFloat(styles.paddingLeft);
const paddingRight = parseFloat(styles.paddingRight);
const contentWidth = width - paddingLeft - paddingRight;
const xInContent = x - paddingLeft;
// Clamp to content area and get ratio
const ratio = Math.max(0, Math.min(1, xInContent / contentWidth));
// Map to 1-9 with proper centering on each character position
const value = Math.min(9, Math.max(1, Math.floor(ratio * 9) + 1));
dispatch('change', value);
}
function handleScaleTouch(event: TouchEvent) {
event.preventDefault();
const target = event.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const touch = event.touches[0];
const x = touch.clientX - rect.left;
const width = rect.width;
// Account for button padding to get actual text area
const styles = window.getComputedStyle(target);
const paddingLeft = parseFloat(styles.paddingLeft);
const paddingRight = parseFloat(styles.paddingRight);
const contentWidth = width - paddingLeft - paddingRight;
const xInContent = x - paddingLeft;
// Clamp to content area and get ratio
const ratio = Math.max(0, Math.min(1, xInContent / contentWidth));
// Map to 1-9 with proper centering on each character position
const value = Math.min(9, Math.max(1, Math.floor(ratio * 9) + 1));
dispatch('change', value);
}
function renderBar(value: number | null): string {
// Fixed scale with 9 positions using ||||#||||
if (value === null) {
return '||||·||||';
}
// Value is 1-9, position the # marker at the right spot
const positions = [
'#||||||||',
'|#|||||||',
'||#||||||',
'|||#|||||',
'||||#||||',
'|||||#|||',
'||||||#||',
'|||||||#|',
'||||||||#'
];
return positions[value - 1];
}
$: barDisplay = renderBar(gradient.value);
</script>
<div class="gradient-container">
<div class="gradient-row">
<div class="term left">
<Tooltip text={gradient.term_left_description}>
{gradient.term_left}
</Tooltip>
</div>
<div class="bar-container">
<button
class="bar"
on:click={handleScaleClick}
on:touchstart={handleScaleTouch}
role="slider"
aria-label="Gradient scale between {gradient.term_left} and {gradient.term_right}"
aria-valuemin="1"
aria-valuemax="9"
aria-valuenow={gradient.value}
title="Click or tap on the scale to set value"
>
{barDisplay}
</button>
</div>
<div class="term right">
<Tooltip text={gradient.term_right_description}>
{gradient.term_right}
</Tooltip>
</div>
</div>
<div class="value-display">
Value: {gradient.value !== null ? gradient.value : ''}
</div>
<div class="controls">
<div class="button-row">
<button
class="na-btn"
class:active={gradient.value === null}
on:click={handleNotApplicable}
aria-label="Mark as not applicable"
title="Mark this gradient as not applicable"
>
N/A
</button>
<button
class="notes-btn"
class:has-notes={gradient.notes}
on:click={() => showNotes = !showNotes}
aria-label="Add notes"
>
{gradient.notes ? '📝' : ''} Notes
</button>
</div>
</div>
{#if showNotes}
<div class="notes-editor">
<textarea
bind:value={notesText}
placeholder="Add notes or reference..."
rows="2"
/>
<div class="notes-buttons">
<button on:click={handleNotesSave}>Save</button>
<button on:click={() => showNotes = false}>Cancel</button>
</div>
</div>
{/if}
</div>
<style>
.gradient-container {
margin: 1rem 0;
padding: 0.5rem;
}
.gradient-row {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
}
.term {
text-align: center;
font-size: 0.9rem;
}
.term.left {
text-align: right;
}
.term.right {
text-align: left;
}
.bar-container {
display: flex;
justify-content: center;
}
.bar {
font-family: 'Courier New', Courier, monospace;
font-size: 1.8rem;
white-space: nowrap;
letter-spacing: 0.2rem;
padding: 0.5rem 1rem;
cursor: pointer;
user-select: none;
min-width: 240px;
text-align: center;
background-color: var(--bg-color);
color: var(--fg-color);
border: 2px solid var(--border-color);
transition: border-color 0.2s;
}
.bar:hover {
border-color: var(--hover-color);
background-color: var(--bg-color);
color: var(--fg-color);
}
.bar:active {
background-color: var(--input-bg);
border-color: var(--fg-color);
color: var(--fg-color);
}
.value-display {
text-align: center;
margin: 0.5rem 0;
font-size: 0.9rem;
}
.controls {
margin-top: 0.5rem;
display: flex;
justify-content: center;
}
.button-row {
display: flex;
gap: 0.5rem;
width: auto;
}
.na-btn {
padding: 0.25rem 0.75rem;
font-size: 0.85rem;
opacity: 0.6;
flex-shrink: 0;
min-height: auto;
width: auto;
}
.na-btn:hover {
opacity: 1;
background-color: transparent;
color: var(--fg-color);
border-color: var(--hover-color);
}
.na-btn.active {
opacity: 1;
background-color: var(--input-bg);
border-color: var(--fg-color);
}
.notes-btn {
padding: 0.25rem 0.75rem;
font-size: 0.85rem;
opacity: 0.6;
min-height: auto;
width: auto;
}
.notes-btn:hover {
opacity: 1;
background-color: transparent;
color: var(--fg-color);
border-color: var(--hover-color);
}
.notes-btn.has-notes {
opacity: 1;
border-color: var(--hover-color);
}
.notes-btn.has-notes:hover {
background-color: transparent;
color: var(--fg-color);
}
.notes-editor {
margin-top: 0.5rem;
padding: 0.5rem;
border: 1px solid var(--border-color);
}
.notes-buttons {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.notes-buttons button {
flex: 1;
}
@media (max-width: 768px) {
.gradient-row {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
gap: 0.5rem;
}
.term.left {
text-align: left;
grid-row: 1;
}
.bar-container {
grid-row: 2;
}
.term.right {
text-align: right;
grid-row: 3;
}
.bar {
font-size: 1.4rem;
min-width: 200px;
padding: 0.5rem;
}
}
</style>

View File

@@ -0,0 +1,216 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { Metadata } from '../types';
export let metadata: Metadata;
const dispatch = createEventDispatcher<{
update: Partial<Metadata>;
}>();
function handleInput(field: keyof Metadata, value: string) {
dispatch('update', { [field]: value || null });
}
function handleToggle(field: keyof Metadata, value: boolean) {
dispatch('update', { [field]: value });
}
function updateTimestamp() {
dispatch('update', { timestamp: new Date().toISOString() });
}
function formatTimestamp(timestamp: string | null): string {
if (!timestamp) return '';
try {
return new Date(timestamp).toLocaleString();
} catch {
return timestamp;
}
}
</script>
<section class="metadata">
<div class="metadata-field">
<label for="protocol">Protocol:</label>
<input
id="protocol"
type="text"
placeholder="[Protocol]"
value={metadata.protocol || ''}
on:input={(e) => handleInput('protocol', e.currentTarget.value)}
/>
</div>
<div class="metadata-field">
<label for="analyst">Analyst:</label>
<input
id="analyst"
type="text"
placeholder="[Analyst]"
value={metadata.analyst || ''}
on:input={(e) => handleInput('analyst', e.currentTarget.value)}
/>
</div>
<div class="metadata-field">
<label for="standpoint">Standpoint:</label>
<input
id="standpoint"
type="text"
placeholder="[Standpoint]"
value={metadata.standpoint || ''}
on:input={(e) => handleInput('standpoint', e.currentTarget.value)}
/>
</div>
<div class="metadata-field">
<label for="timestamp">Timestamp:</label>
<div class="timestamp-display">
<input
id="timestamp"
type="text"
readonly
value={formatTimestamp(metadata.timestamp)}
placeholder="[Auto-generated]"
/>
<button on:click={updateTimestamp} aria-label="Update timestamp">
🕐 Update
</button>
</div>
</div>
<div class="metadata-field toggle-field">
<label for="shortform">Short Form:</label>
<div class="toggle-container">
<label class="toggle-switch">
<input
id="shortform"
type="checkbox"
checked={metadata.shortform}
on:change={(e) => handleToggle('shortform', e.currentTarget.checked)}
/>
<span class="slider"></span>
</label>
<span class="toggle-label">{metadata.shortform ? 'Enabled' : 'Disabled'}</span>
</div>
</div>
</section>
<style>
.metadata {
margin: 2rem 0;
padding: 1rem;
border: 2px solid var(--border-color);
}
.metadata-field {
margin: 0.5rem 0;
}
label {
display: block;
margin-bottom: 0.25rem;
font-weight: bold;
}
.timestamp-display {
display: flex;
gap: 0.5rem;
}
.timestamp-display input {
flex: 1;
opacity: 0.7;
}
.timestamp-display button {
flex-shrink: 0;
}
.toggle-field {
display: flex;
align-items: center;
gap: 1rem;
}
.toggle-field > label {
margin-bottom: 0;
}
.toggle-container {
display: flex;
align-items: center;
gap: 0.75rem;
}
.toggle-switch {
position: relative;
display: inline-block;
width: 50px;
height: 26px;
margin-bottom: 0;
cursor: pointer;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
border-radius: 26px;
transition: 0.3s;
}
.slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 3px;
bottom: 3px;
background-color: white;
border-radius: 50%;
transition: 0.3s;
}
.toggle-switch input:checked + .slider {
background-color: var(--primary-color, #4CAF50);
}
.toggle-switch input:checked + .slider:before {
transform: translateX(24px);
}
.toggle-label {
font-size: 0.9rem;
opacity: 0.8;
}
@media (max-width: 768px) {
.metadata {
padding: 0.5rem;
}
.timestamp-display {
flex-direction: column;
}
.timestamp-display button {
width: 100%;
}
.toggle-field {
flex-direction: column;
align-items: flex-start;
}
}
</style>

View File

@@ -0,0 +1,120 @@
<script lang="ts">
export let text: string;
let showTooltip = false;
let touchTimer: number;
let isLongPress = false;
function handleTouchStart() {
isLongPress = false;
touchTimer = window.setTimeout(() => {
showTooltip = true;
isLongPress = true;
}, 500); // Show after 500ms press
}
function handleTouchEnd() {
clearTimeout(touchTimer);
if (isLongPress) {
// Keep tooltip visible for a moment after long press release
setTimeout(() => {
showTooltip = false;
}, 2000);
}
}
function handleClick() {
// Toggle tooltip on click/tap
if (!isLongPress) {
showTooltip = !showTooltip;
// Auto-hide after 3 seconds
if (showTooltip) {
setTimeout(() => {
showTooltip = false;
}, 3000);
}
}
isLongPress = false;
}
function handleMouseEnter() {
showTooltip = true;
}
function handleMouseLeave() {
showTooltip = false;
}
</script>
<span
class="tooltip-wrapper"
on:mouseenter={handleMouseEnter}
on:mouseleave={handleMouseLeave}
on:touchstart={handleTouchStart}
on:touchend={handleTouchEnd}
on:touchcancel={handleTouchEnd}
on:click={handleClick}
role="button"
tabindex="0"
aria-label={text}
aria-expanded={showTooltip}
>
<slot />
{#if showTooltip}
<span class="tooltip-text" role="status" aria-live="polite">
{text}
</span>
{/if}
</span>
<style>
.tooltip-wrapper {
position: relative;
display: inline-block;
cursor: help;
border-bottom: 1px dotted var(--fg-color);
user-select: none;
}
.tooltip-text {
position: absolute;
bottom: 125%;
left: 50%;
transform: translateX(-50%);
background-color: var(--bg-color);
color: var(--fg-color);
border: 2px solid var(--border-color);
padding: 0.5rem;
z-index: 1000;
width: max-content;
max-width: 300px;
font-size: 0.85rem;
line-height: 1.4;
text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
@media (prefers-color-scheme: dark) {
.tooltip-text {
box-shadow: 0 2px 8px rgba(255, 255, 255, 0.2);
}
}
.tooltip-text::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
margin-left: -5px;
border-width: 5px;
border-style: solid;
border-color: var(--border-color) transparent transparent transparent;
}
@media (max-width: 768px) {
.tooltip-text {
max-width: 250px;
font-size: 0.8rem;
}
}
</style>

21
bicorder-app/src/main.ts Normal file
View File

@@ -0,0 +1,21 @@
import './app.css'
import App from './App.svelte'
import { registerSW } from 'virtual:pwa-register'
// Register service worker
const updateSW = registerSW({
onNeedRefresh() {
if (confirm('New content available. Reload?')) {
updateSW(true)
}
},
onOfflineReady() {
console.log('App ready to work offline')
},
})
const app = new App({
target: document.getElementById('app')!,
})
export default app

50
bicorder-app/src/types.ts Normal file
View File

@@ -0,0 +1,50 @@
export interface Gradient {
term_left: string
term_left_description: string
term_right: string
term_right_description: string
value: number | null
notes: string | null
shortform: boolean
}
export interface DiagnosticSet {
set_name: string
set_description: string
gradients: Gradient[]
}
export interface AnalysisGradient {
term_left: string
term_left_description: string
term_right: string
term_right_description: string
instructions: string
automated: boolean
value: number | null
notes: string | null
}
export interface Metadata {
protocol: string | null
analyst: string | null
standpoint: string | null
timestamp: string | null
shortform: boolean
}
export interface BicorderData {
name: string
schema: string
version: string
description: string
author: string
date_modified: string
metadata: Metadata
diagnostic: DiagnosticSet[]
analysis: AnalysisGradient[]
}
export interface BicorderState extends BicorderData {
// Add any additional runtime state here
}

12
bicorder-app/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />
declare const __BICORDER_DATA__: any
interface ImportMetaEnv {
readonly VITE_APP_TITLE: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}