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

26
bicorder-app/.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# PWA
dev-dist

150
bicorder-app/README.md Normal file
View File

@@ -0,0 +1,150 @@
# Protocol Bicorder - Web App
A Svelte Progressive Web App (PWA) for carrying out protocol diagnostics as defined in `../bicorder.json`.
## Features
- **Single-page diagnostic tool** with ASCII-styled interface
- **Touch-friendly controls** optimized for mobile devices
- **Shortform toggle** - switch between full (23 gradients) and short (10 gradients) versions
- **Tooltips** on all gradient terms (long-press on mobile, hover on desktop)
- **Editable metadata** fields with auto-generated timestamps
- **Auto-calculated analysis** section (hardness/softness, polarized/centrist)
- **Citation support** for each gradient value
- **Local storage** - your progress is automatically saved
- **Export to JSON** - download your completed diagnostic
- **Share functionality** - use Web Share API on supported devices
- **Public upload** - submit readings directly to the public repository
- **Full PWA** - installable, works offline
- **Privacy-focused** - no telemetry, no external fonts, works completely offline
## Development
```bash
# Install dependencies
npm install
# Start development server (http://localhost:5173)
npm run dev
# Build for production
npm run build
# Preview production build (http://localhost:4173)
npm run preview
```
**Important:** The app must be served via HTTP/HTTPS (not opened directly as `file://`). Modern browsers block ES modules and service workers from the file protocol for security reasons. Always use `npm run preview` or deploy to a web server to test the built app.
## Build Process
The app reads `../bicorder.json` at build time and constructs the interface based on its structure. To update the diagnostic:
1. Edit `../bicorder.json`
2. Run `npm run update` (or `npm run build`)
3. The app will automatically reflect the new structure
Alternatively, run `./update-diagnostic.sh` for a friendlier update experience.
## Deployment
The built app is in the `dist/` folder. You can deploy it to any static hosting service:
- **GitHub Pages**: Push the dist folder
- **Netlify**: Connect your repo and set build command to `npm run build`
- **Vercel**: Same as Netlify
- **Static server**: Copy the dist folder contents to your web server
### Quick local server options
If you don't have Node.js available, you can serve the `dist/` folder with:
```bash
# Use the provided script (tries Python/PHP automatically)
./serve-local.sh
# Or manually:
# Python 3
python3 -m http.server 8000 --directory dist
# PHP
php -S localhost:8000 -t dist
# Any other static file server
```
Then open http://localhost:8000 in your browser.
## Usage
### Filling out the diagnostic
1. Fill in the metadata fields (Protocol, Analyst, Standpoint)
2. **Toggle shortform mode** (optional) to show only the 10 core gradients
3. For each gradient, tap/click the numbered buttons (1-9) to set values
- 1 = strongly toward the left term
- 5 = balanced/neutral
- 9 = strongly toward the right term
4. Optionally add citations for each gradient value
5. The Analysis section is automatically calculated based on your inputs
### Exporting and sharing results
- **Export JSON**: Downloads the diagnostic as a JSON file
- **Share**: Uses your device's share functionality (mobile)
- **Upload**: Submit your readings to the public repository
### Public upload
When you click **Upload**, your readings will be:
- Posted to the public repository: [protocol-bicorder-data](https://git.medlab.host/ntnsndr/protocol-bicorder-data)
- Licensed to the public domain (CC0)
- Committed with your analyst name and protocol name
- Stored in the `readings/` directory with a timestamp
All uploaded readings are public and available for research and analysis. By uploading, you consent to releasing your diagnostic under a public domain license.
## Browser Support
- Modern browsers (Chrome, Firefox, Safari, Edge)
- Mobile browsers (iOS Safari, Android Chrome)
- PWA installation supported on Android, iOS 16.4+, and desktop browsers
## Customization
### Styling
The app uses CSS custom properties for theming. Edit `src/app.css` to change colors:
```css
--bg-color: #000000; /* Background */
--fg-color: #00ff00; /* Foreground/text */
--border-color: #00ff00; /* Borders */
--hover-color: #00cc00; /* Hover states */
```
### Icons
Replace `public/icon.svg` with your own icon. For better PWA support, also generate PNG versions:
```bash
# Example using ImageMagick (if available)
convert -background none -resize 192x192 public/icon.svg public/icon-192.png
convert -background none -resize 512x512 public/icon.svg public/icon-512.png
```
Then update `vite.config.ts` to reference the PNG icons in the manifest.
## Architecture
- **Framework**: Svelte 4 with TypeScript
- **Build tool**: Vite 5
- **PWA**: vite-plugin-pwa with Workbox
- **State management**: Svelte stores + localStorage
- **Data source**: bicorder.json (build-time integration)
## 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.
[![Hippocratic License HL3-CORE](https://img.shields.io/static/v1?label=Hippocratic%20License&message=HL3-CORE&labelColor=5e2751&color=bc8c3d)](https://firstdonoharm.dev/version/3/0/core.html)

16
bicorder-app/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<meta name="description" content="A diagnostic tool for the study of protocols" />
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#000000" media="(prefers-color-scheme: dark)" />
<title>Protocol Bicorder</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

6590
bicorder-app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
bicorder-app/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "bicorder-web",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"update": "npm run build && echo '\nApp updated! Run npm run preview to test locally.'"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@tsconfig/svelte": "^5.0.0",
"svelte": "^4.2.0",
"svelte-check": "^3.6.0",
"tslib": "^2.6.0",
"typescript": "^5.2.0",
"vite": "^5.0.0",
"vite-plugin-pwa": "^0.17.0"
},
"dependencies": {
"workbox-window": "^7.0.0"
}
}

View File

@@ -0,0 +1 @@
placeholder

View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" fill="#ffffff"/>
<text x="50" y="35" font-family="monospace" font-size="16" fill="#000000" text-anchor="middle">
Protocol
</text>
<text x="50" y="55" font-family="monospace" font-size="20" fill="#000000" text-anchor="middle" font-weight="bold">
BICORDER
</text>
<rect x="20" y="65" width="60" height="10" fill="none" stroke="#000000" stroke-width="2"/>
<rect x="22" y="67" width="20" height="6" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 535 B

View File

@@ -0,0 +1,250 @@
{
"name": "Protocol Bicorder",
"schema": "bicorder.schema.json",
"version": "1.1.0",
"description": "A diagnostic tool for the study of protocols",
"author": "Nathan Schneider",
"date_modified": "2025-11-21",
"metadata": {
"protocol": "IB Learner Profile",
"analyst": "Kids at the school",
"standpoint": "Students at the school",
"timestamp": "2025-11-24T03:16:26.468Z"
},
"diagnostic": [
{
"set_name": "Design",
"set_description": "How the protocol is created and remembered",
"gradients": [
{
"term_left": "explicit",
"term_left_description": "The design is stated explicitly somewhere that is accessible to participants",
"term_right": "implicit",
"term_right_description": "The design is not stated explicitly but is learned by participants in another way",
"value": 2,
"notes": null
},
{
"term_left": "precise",
"term_left_description": "The design is specified with a high level of precision that eliminates ambiguity in implementation",
"term_right": "interpretive",
"term_right_description": "The design is ambiguous, allowing participants a wide range of interpretation",
"value": 4,
"notes": null
},
{
"term_left": "institutional",
"term_left_description": "Design occurs through processes that involve powerful institutions and widespread recognition as normative",
"term_right": "vernacular",
"term_right_description": "Design occurs through evolving, peer-to-peer community interactions in order to suit participant-defined goals",
"value": 1,
"notes": null
},
{
"term_left": "documenting",
"term_left_description": "The primary purpose is to document or validate activity that is occurring",
"term_right": "enabling",
"term_right_description": "The primary purpose is to enable activity that might not happen otherwise",
"value": 5,
"notes": null
},
{
"term_left": "static",
"term_left_description": "Designed to be as fixed and unchanging as possible",
"term_right": "malleable",
"term_right_description": "Designed to be changed by participants according to evolving needs",
"value": 1,
"notes": null
},
{
"term_left": "technical",
"term_left_description": "Primarily concerned with interactions among technologies",
"term_right": "social",
"term_right_description": "Primarily concerned with interactions among people or groups",
"value": 9,
"notes": null
},
{
"term_left": "universal",
"term_left_description": "Addressed to a global audience",
"term_right": "particular",
"term_right_description": "Addressed to a specific community",
"value": 5,
"notes": null
},
{
"term_left": "durable",
"term_left_description": "Designed to be persistently available",
"term_right": "ephemeral",
"term_right_description": "Designed to vanish when no longer needed",
"value": 1,
"notes": null
}
]
},
{
"set_name": "Entanglement",
"set_description": "How the protocol relates with participant agents",
"gradients": [
{
"term_left": "macro",
"term_left_description": "Operates at large scales involving many participants or broad scope",
"term_right": "micro",
"term_right_description": "Operates at small scales with few participants or narrow scope",
"value": 3,
"notes": null
},
{
"term_left": "sovereign",
"term_left_description": "A distinctive operating logic, not subject to any other entity",
"term_right": "subsidiary",
"term_right_description": "An operating logic under the control of a particular entity",
"value": 8,
"notes": null
},
{
"term_left": "self-enforcing",
"term_left_description": "Rules are automatically enforced through its own mechanisms",
"term_right": "enforced",
"term_right_description": "Rules require external enforcement by authorities or institutions",
"value": 7,
"notes": null
},
{
"term_left": "abstract",
"term_left_description": "Participants learn the protocol by studying it intellectually",
"term_right": "embodied",
"term_right_description": "Participants learn the protocol by physically practicing it",
"value": 2,
"notes": null
},
{
"term_left": "obligatory",
"term_left_description": "Participation is compulsory for a certain class of agents",
"term_right": "voluntary",
"term_right_description": "Participation in the protocol is optional and not coerced",
"value": 2,
"notes": null
},
{
"term_left": "flocking",
"term_left_description": "Coordination occurs through centralized direction or direct mimicry",
"term_right": "swarming",
"term_right_description": "Coordination occurs through distributed interactions without central direction",
"value": 8,
"notes": null
},
{
"term_left": "defensible",
"term_left_description": "Strong boundaries and protections against external influence",
"term_right": "exposed",
"term_right_description": "Weak boundaries and vulnerable to external influence",
"value": null,
"notes": null
},
{
"term_left": "exclusive",
"term_left_description": "Excludes the use of other protocols that might be available to adopt",
"term_right": "non-exclusive",
"term_right_description": "Does not exclude the use of any other protocols",
"value": 8,
"notes": null
}
]
},
{
"set_name": "Experience",
"set_description": "How the protocol is perceived in the context of its implementation",
"gradients": [
{
"term_left": "sufficient",
"term_left_description": "Adequately meets the needs and goals of participants",
"term_right": "insufficient",
"term_right_description": "Does not, on its own, adequately meet the needs and goals of participants",
"value": 8,
"notes": null
},
{
"term_left": "crystallized",
"term_left_description": "Content and meaning are settled and widely agreed upon",
"term_right": "contested",
"term_right_description": "Content and meaning are disputed or under debate",
"value": 1,
"notes": null
},
{
"term_left": "trust-evading",
"term_left_description": "Minimizes the need for trust among participants",
"term_right": "trust-inducing",
"term_right_description": "Relies on or cultivates trust among participants",
"value": 9,
"notes": null
},
{
"term_left": "predictable",
"term_left_description": "Produces expected and consistent outcomes",
"term_right": "emergent",
"term_right_description": "Produces unexpected or novel outcomes",
"value": 9,
"notes": null
},
{
"term_left": "exclusion",
"term_left_description": "The protocol creates barriers or excludes certain participants",
"term_right": "inclusion",
"term_right_description": "The protocol reduces barriers and includes diverse participants",
"value": 9,
"notes": null
},
{
"term_left": "Kafka",
"term_left_description": "Fosters experiences of absurd complexity, alienation, and powerlessness",
"term_right": "Whitehead",
"term_right_description": "Enables participants to carry out desired activities with less work or thought",
"value": 6,
"notes": null
},
{
"term_left": "dead",
"term_left_description": "Not actively utilized by relevant participants",
"term_right": "alive",
"term_right_description": "Actively utilized by relevant participants",
"value": 8,
"notes": null
}
]
}
],
"analysis": [
{
"term_left": "hardness",
"term_left_description": "The protocol tends toward properties characterized by hardness",
"term_right": "softness",
"term_right_description": "The protocol tends toward properties characterized by softness",
"instructions": "Take all the 'value' fields in the gradients above and determine a mean. Round it to the nearest integer. That is the 'value' here.",
"automated": true,
"value": 5,
"notes": null
},
{
"term_left": "polarized",
"term_left_description": "The analyst tended toward more extreme high or low readings",
"term_right": "centrist",
"term_right_description": "The analyst tended toward readings at the middle of the gradients",
"instructions": "Take all the 'value' fields in the gradients above. Assess their degree of polarization. For instance, if all the values are either 1 or 9, the output would be 1, and if all of them are 5, the output would be 9.",
"automated": true,
"value": 3,
"notes": null
},
{
"term_left": "not useful",
"term_left_description": "The bicorder was not useful or relevant for analyzing this protocol",
"term_right": "very useful",
"term_right_description": "The bicorder was very useful and relevant for analyzing this protocol",
"instructions": "Evaluate the usefulness of this bicorder as a tool for analyzing this protocol, considering whether the gradient terms seemed revealing or irrelevant.",
"automated": false,
"value": 7,
"notes": null
}
]
}

25
bicorder-app/serve-local.sh Executable file
View File

@@ -0,0 +1,25 @@
#!/bin/bash
# Simple script to serve the built app locally
if [ ! -d "dist" ]; then
echo "Error: dist folder not found. Run 'npm run build' first."
exit 1
fi
echo "Starting local server..."
echo "Open your browser to: http://localhost:8000"
echo "Press Ctrl+C to stop the server"
echo ""
# Try different server options in order of preference
if command -v python3 &> /dev/null; then
python3 -m http.server 8000 --directory dist
elif command -v python &> /dev/null; then
cd dist && python -m SimpleHTTPServer 8000
elif command -v php &> /dev/null; then
php -S localhost:8000 -t dist
else
echo "Error: No suitable HTTP server found."
echo "Please install Python 3, Python 2, or PHP, or use 'npm run preview'"
exit 1
fi

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
}

View File

@@ -0,0 +1,5 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
export default {
preprocess: vitePreprocess(),
}

View File

@@ -0,0 +1,15 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"resolveJsonModule": true,
"allowJs": true,
"checkJs": true,
"isolatedModules": true,
"moduleDetection": "force"
},
"include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,22 @@
#!/bin/bash
# Update the diagnostic app when bicorder.json changes
echo "Checking for bicorder.json..."
if [ ! -f "../bicorder.json" ]; then
echo "Error: ../bicorder.json not found!"
exit 1
fi
echo "Building app with latest bicorder.json..."
npm run build
if [ $? -eq 0 ]; then
echo ""
echo "✓ Build successful!"
echo "The app has been updated with the latest bicorder.json structure."
echo "Deploy the 'dist' folder to your web server or run 'npm run preview' to test."
else
echo ""
echo "✗ Build failed. Please check the errors above."
exit 1
fi

View File

@@ -0,0 +1,43 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import { VitePWA } from 'vite-plugin-pwa'
import fs from 'fs'
import path from 'path'
// Read bicorder.json at build time
const bicorderData = JSON.parse(
fs.readFileSync(path.resolve(__dirname, '../bicorder.json'), 'utf-8')
)
export default defineConfig({
base: './',
plugins: [
svelte(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico'],
manifest: {
name: 'Protocol Bicorder',
short_name: 'Bicorder',
description: 'A diagnostic tool for the study of protocols',
theme_color: '#ffffff',
background_color: '#ffffff',
display: 'standalone',
icons: [
{
src: '/icon.svg',
sizes: 'any',
type: 'image/svg+xml',
purpose: 'any'
}
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,json}']
}
})
],
define: {
'__BICORDER_DATA__': JSON.stringify(bicorderData)
}
})