Compare commits
9 Commits
04eee1360f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
240401b9cb | ||
|
|
aac6334380 | ||
|
|
b541f6049e | ||
|
|
3a55d3dbb9 | ||
|
|
7be67c9eb5 | ||
|
|
dd5f71e7ad | ||
|
|
998c93b3f9 | ||
|
|
fa527bd1f1 | ||
|
|
7ba4a069a2 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
tmp
|
||||
analysis/venv
|
||||
.aider*
|
||||
|
||||
26
README.md
26
README.md
@@ -14,6 +14,8 @@ The bicorder consists of three components:
|
||||
* `diagnostic` composed of sets of gradients that measure the analyst's interpretation of the protocol
|
||||
* `analysis` that interprets the diagnostic data
|
||||
|
||||
A `shortform` option is available for a version of the bicorder that uses a smaller number of gradients. These are the gradients that, according to the [`analysis/`](analysis/), are the most salient and predictive of the values of other gradients.
|
||||
|
||||
### Metadata
|
||||
|
||||
There are several pieces of information that provide metadata about a given reading with the bicorder. More details about the data formats for each input are provided in `bicorder.schema.json`.
|
||||
@@ -22,6 +24,7 @@ There are several pieces of information that provide metadata about a given read
|
||||
* `analyst`: Name or other identifier of the analyst conducting the diagnostic
|
||||
* `standpoint`: Describe, even at some length, the relationship between the analyst and the protocol, including any relevant context that could affect the diagnostic readings
|
||||
* `timestamp`: A timestamp for when the reading occurred
|
||||
* `shortform`: Indicates whether the diagnostic was conducted using the abbreviated list of gradients
|
||||
|
||||
### Diagnostic
|
||||
|
||||
@@ -29,7 +32,11 @@ To carry out the diagnostic, the analyst should consider the protocol from the p
|
||||
|
||||
This is inevitably an interpretive exercise, but do your best to identify the most accurate `value`, with `1` being closest to `term_left` and `9` being closest to `term_right`.
|
||||
|
||||
Choosing a `value` in the middle, such as `5`, can mean "a bit of both" or "neither."
|
||||
Choosing a `value` in the middle, such as `5`, can mean "a bit of both." Leaving the gradient `value` as `null` means "not applicable."
|
||||
|
||||
There is a `notes` field for the analyst to add additional context or explanation.
|
||||
|
||||
The `shortform` boolean indicates whether this gradient is included in the abbreviated version of the bicorder.
|
||||
|
||||
### Analysis
|
||||
|
||||
@@ -72,11 +79,7 @@ To mark a gradient in a particular place, it is represented with a `#` like this
|
||||
|
||||
### Human-usable web app
|
||||
|
||||
* Create an online tool for reporting a protocol
|
||||
- Web app for fun that can be used with a mobile phone
|
||||
- Include tooltips for descriptions
|
||||
- Auto-analyze
|
||||
- Enable it to produce a JSON printout
|
||||
A progressive web app (PWA) for using the bicorder is located in [`bicorder-app/`](bicorder-app).
|
||||
|
||||
|
||||
## Synthetic data analysis
|
||||
@@ -87,15 +90,6 @@ See [`analysis/`](analysis/) for complete documentation and materials.
|
||||
|
||||
|
||||
<!---
|
||||
## To do
|
||||
|
||||
* Try data analysis
|
||||
* Continue iterating on the bicorder design
|
||||
* Develop the web app in a way that is tightly bound to the canonical JSON and schema
|
||||
* Fill out citations
|
||||
* Create script to output a book-friendly chart, utilizing ascii_bicorder but also adding descriptions and footnoted citations
|
||||
* Deploy to git.medlab.host
|
||||
|
||||
### Gradient citations
|
||||
|
||||
- implicit / explicit
|
||||
@@ -118,6 +112,6 @@ See [`analysis/`](analysis/) for complete documentation and materials.
|
||||
|
||||
## 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 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.
|
||||
|
||||
[](https://firstdonoharm.dev/version/3/0/core.html)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
This directory concerns a synthetic data analysis conducted with the Protocol Bicorder.
|
||||
|
||||
Scripts were created with the assistance of Claude Code. Data processing was done largely with either local models or the Ollama cloud service, which does not retain user data. Thanks to [Seth Frey (UC Davis)](https://enfascination.com/) for guidance.
|
||||
Scripts were created with the assistance of Claude Code. Data processing was done largely with either local models or the Ollama cloud service, which does not retain user data. Thanks to [Seth Frey (UC Davis)](https://enfascination.com/) for guidance, but all mistakes are the responsibility of the author, [Nathan Schneider](https://nathanschneider.info). **WARNING: This is the work of a researcher working with AI outside their field of expertise and should be treated as a playful experiment, not a model of rigorous methodology.**
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -46,6 +46,8 @@ The result was a CSV-formatted list of protocols (`protocols_edited.csv`, n=411)
|
||||
|
||||
### Initial diagnostic
|
||||
|
||||
This diagnostic used the file now at `bicorder_analyzed.json`, though the scripts are set up to analyze `../bicorder.json`. That file has since been updated based on this analysis.
|
||||
|
||||
For each row in the dataset, and on each gradient, a series of scripts prompts the LLM to apply each gradient to the protocol. The outputs are then added to a CSV output file.
|
||||
|
||||
The result was a CSV-formatted list of protocols (`diagnostic_output.csv`, n=411).
|
||||
|
||||
0
analysis/bicorder_analyze.py
Normal file → Executable file
0
analysis/bicorder_analyze.py
Normal file → Executable file
241
analysis/bicorder_analyzed.json
Normal file
241
analysis/bicorder_analyzed.json
Normal file
@@ -0,0 +1,241 @@
|
||||
{
|
||||
"name": "Protocol Bicorder",
|
||||
"schema": "bicorder.schema.json",
|
||||
"version": "1.0.0",
|
||||
"description": "A diagnostic tool for the study of protocols",
|
||||
"author": "Nathan Schneider",
|
||||
"date_modified": "YYYY-MM-DD",
|
||||
|
||||
"metadata": {
|
||||
"protocol": null,
|
||||
"analyst": null,
|
||||
"standpoint": null,
|
||||
"timestamp": null
|
||||
},
|
||||
|
||||
"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": null,
|
||||
"citation": 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": null,
|
||||
"citation": null
|
||||
},
|
||||
{
|
||||
"term_left": "elite",
|
||||
"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": null,
|
||||
"citation": 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": null,
|
||||
"citation": 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": null,
|
||||
"citation": 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": null,
|
||||
"citation": null
|
||||
},
|
||||
{
|
||||
"term_left": "universal",
|
||||
"term_left_description": "Addressed to a global audience",
|
||||
"term_right": "particular",
|
||||
"term_right_description": "Addressed to a specific community",
|
||||
"value": null,
|
||||
"citation": 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": null,
|
||||
"citation": 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": null,
|
||||
"citation": 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": null,
|
||||
"citation": 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": null,
|
||||
"citation": 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": null,
|
||||
"citation": 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": null,
|
||||
"citation": 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": null,
|
||||
"citation": 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,
|
||||
"citation": 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": null,
|
||||
"citation": 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": null,
|
||||
"citation": 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": null,
|
||||
"citation": 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": null,
|
||||
"citation": null
|
||||
},
|
||||
{
|
||||
"term_left": "predictable",
|
||||
"term_left_description": "Produces expected and consistent outcomes",
|
||||
"term_right": "emergent",
|
||||
"term_right_description": "Produces unexpected or novel outcomes",
|
||||
"value": null,
|
||||
"citation": 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": null,
|
||||
"citation": 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": null,
|
||||
"citation": 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": null,
|
||||
"citation": 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.",
|
||||
"value": null,
|
||||
"citation": 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.",
|
||||
"value": null,
|
||||
"citation": null
|
||||
}
|
||||
]
|
||||
}
|
||||
0
analysis/bicorder_batch.py
Normal file → Executable file
0
analysis/bicorder_batch.py
Normal file → Executable file
0
analysis/bicorder_init.py
Normal file → Executable file
0
analysis/bicorder_init.py
Normal file → Executable file
0
analysis/bicorder_query.py
Normal file → Executable file
0
analysis/bicorder_query.py
Normal file → Executable file
0
analysis/chunk.sh
Executable file → Normal file
0
analysis/chunk.sh
Executable file → Normal file
0
analysis/multivariate_analysis.py
Normal file → Executable file
0
analysis/multivariate_analysis.py
Normal file → Executable file
@@ -8,7 +8,7 @@ import argparse
|
||||
import sys
|
||||
|
||||
|
||||
def center_text(text, width=80):
|
||||
def center_text(text, width):
|
||||
"""Center text within a given width"""
|
||||
return text.center(width)
|
||||
|
||||
@@ -31,7 +31,7 @@ def format_gradient_bar(value):
|
||||
return "[" + "".join(bars) + "]"
|
||||
|
||||
|
||||
def format_gradient_line(term_left, term_right, value, left_width=18, right_width=18):
|
||||
def format_gradient_line(term_left, term_right, value, left_width, right_width, center_width):
|
||||
"""
|
||||
Format a gradient line with proper spacing.
|
||||
Example: " explicit < [|||||||||] > implicit "
|
||||
@@ -39,7 +39,7 @@ def format_gradient_line(term_left, term_right, value, left_width=18, right_widt
|
||||
bar = format_gradient_bar(value)
|
||||
# Right-align the left term, add the bar, then left-align the right term
|
||||
line = f"{term_left.rjust(left_width)} < {bar} > {term_right.ljust(right_width)}"
|
||||
return center_text(line)
|
||||
return center_text(line, center_width)
|
||||
|
||||
|
||||
def format_metadata_field(field_value, field_name):
|
||||
@@ -74,41 +74,65 @@ def generate_bicorder_text(json_data):
|
||||
max_left_width = max(max_left_width, len(term_left))
|
||||
max_right_width = max(max_right_width, len(term_right))
|
||||
|
||||
# Calculate the width needed for centering
|
||||
# Gradient line format: "{left_term} < [|||||||||] > {right_term}"
|
||||
# That's: max_left_width + 3 + 11 + 3 + max_right_width
|
||||
gradient_line_width = max_left_width + max_right_width + 17
|
||||
|
||||
# Also check metadata and headers
|
||||
metadata = json_data.get("metadata", {})
|
||||
max_text_width = max(
|
||||
len("Protocol"),
|
||||
len("BICORDER"),
|
||||
len(format_metadata_field(metadata.get("protocol"), "Protocol")),
|
||||
len(format_metadata_field(metadata.get("analyst"), "Analyst")),
|
||||
len(format_metadata_field(metadata.get("standpoint"), "Standpoint")),
|
||||
len(format_metadata_field(metadata.get("timestamp"), "Timestamp")),
|
||||
len("ANALYSIS")
|
||||
)
|
||||
|
||||
# Check diagnostic set names
|
||||
for diagnostic_set in json_data.get("diagnostic", []):
|
||||
set_name = diagnostic_set.get("set_name", "").upper()
|
||||
max_text_width = max(max_text_width, len(set_name))
|
||||
|
||||
# Use the maximum of gradient line width and text width
|
||||
center_width = max(gradient_line_width, max_text_width)
|
||||
|
||||
# Header
|
||||
lines.append(center_text("Protocol"))
|
||||
lines.append(center_text("BICORDER"))
|
||||
lines.append(center_text("Protocol", center_width))
|
||||
lines.append(center_text("BICORDER", center_width))
|
||||
lines.append("")
|
||||
|
||||
# Metadata section
|
||||
metadata = json_data.get("metadata", {})
|
||||
lines.append(center_text(format_metadata_field(metadata.get("protocol"), "Protocol")))
|
||||
lines.append(center_text(format_metadata_field(metadata.get("analyst"), "Analyst")))
|
||||
lines.append(center_text(format_metadata_field(metadata.get("standpoint"), "Standpoint")))
|
||||
lines.append(center_text(format_metadata_field(metadata.get("timestamp"), "Timestamp")))
|
||||
lines.append(center_text(format_metadata_field(metadata.get("protocol"), "Protocol"), center_width))
|
||||
lines.append(center_text(format_metadata_field(metadata.get("analyst"), "Analyst"), center_width))
|
||||
lines.append(center_text(format_metadata_field(metadata.get("standpoint"), "Standpoint"), center_width))
|
||||
lines.append(center_text(format_metadata_field(metadata.get("timestamp"), "Timestamp"), center_width))
|
||||
lines.append("")
|
||||
|
||||
# Diagnostic sections
|
||||
for diagnostic_set in json_data.get("diagnostic", []):
|
||||
set_name = diagnostic_set.get("set_name", "").upper()
|
||||
lines.append(center_text(set_name))
|
||||
lines.append(center_text(set_name, center_width))
|
||||
|
||||
for gradient in diagnostic_set.get("gradients", []):
|
||||
term_left = gradient.get("term_left", "")
|
||||
term_right = gradient.get("term_right", "")
|
||||
value = gradient.get("value")
|
||||
|
||||
lines.append(format_gradient_line(term_left, term_right, value, max_left_width, max_right_width))
|
||||
lines.append(format_gradient_line(term_left, term_right, value, max_left_width, max_right_width, center_width))
|
||||
|
||||
lines.append("")
|
||||
|
||||
# Analysis section
|
||||
lines.append(center_text("ANALYSIS"))
|
||||
lines.append(center_text("ANALYSIS", center_width))
|
||||
for analysis_item in json_data.get("analysis", []):
|
||||
term_left = analysis_item.get("term_left", "")
|
||||
term_right = analysis_item.get("term_right", "")
|
||||
value = analysis_item.get("value")
|
||||
|
||||
lines.append(format_gradient_line(term_left, term_right, value, max_left_width, max_right_width))
|
||||
lines.append(format_gradient_line(term_left, term_right, value, max_left_width, max_right_width, center_width))
|
||||
|
||||
lines.append("")
|
||||
|
||||
|
||||
26
bicorder-app/.gitignore
vendored
Normal file
26
bicorder-app/.gitignore
vendored
Normal 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
150
bicorder-app/README.md
Normal 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.
|
||||
|
||||
[](https://firstdonoharm.dev/version/3/0/core.html)
|
||||
16
bicorder-app/index.html
Normal file
16
bicorder-app/index.html
Normal 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
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
25
bicorder-app/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
bicorder-app/public/favicon.ico
Normal file
1
bicorder-app/public/favicon.ico
Normal file
@@ -0,0 +1 @@
|
||||
placeholder
|
||||
11
bicorder-app/public/icon.svg
Normal file
11
bicorder-app/public/icon.svg
Normal 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 |
@@ -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
25
bicorder-app/serve-local.sh
Executable 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
227
bicorder-app/src/App.svelte
Normal 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
111
bicorder-app/src/app.css
Normal 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 */
|
||||
}
|
||||
}
|
||||
368
bicorder-app/src/components/AnalysisDisplay.svelte
Normal file
368
bicorder-app/src/components/AnalysisDisplay.svelte
Normal 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>
|
||||
241
bicorder-app/src/components/ExportControls.svelte
Normal file
241
bicorder-app/src/components/ExportControls.svelte
Normal file
@@ -0,0 +1,241 @@
|
||||
<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${data.version}`;
|
||||
|
||||
// Prepare the content (base64 encoded)
|
||||
const jsonContent = JSON.stringify(data, null, 2);
|
||||
const base64Content = btoa(unescape(encodeURIComponent(jsonContent)));
|
||||
|
||||
// Get current timestamp for commit
|
||||
const now = new Date();
|
||||
const commitDate = now.toISOString();
|
||||
|
||||
// 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',
|
||||
dates: {
|
||||
author: commitDate,
|
||||
committer: commitDate,
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
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>
|
||||
336
bicorder-app/src/components/GradientSlider.svelte
Normal file
336
bicorder-app/src/components/GradientSlider.svelte
Normal 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>
|
||||
216
bicorder-app/src/components/MetadataFields.svelte
Normal file
216
bicorder-app/src/components/MetadataFields.svelte
Normal 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>
|
||||
120
bicorder-app/src/components/Tooltip.svelte
Normal file
120
bicorder-app/src/components/Tooltip.svelte
Normal 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
21
bicorder-app/src/main.ts
Normal 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
50
bicorder-app/src/types.ts
Normal 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
12
bicorder-app/src/vite-env.d.ts
vendored
Normal 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
|
||||
}
|
||||
5
bicorder-app/svelte.config.js
Normal file
5
bicorder-app/svelte.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
}
|
||||
15
bicorder-app/tsconfig.json
Normal file
15
bicorder-app/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
10
bicorder-app/tsconfig.node.json
Normal file
10
bicorder-app/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
22
bicorder-app/update-diagnostic.sh
Executable file
22
bicorder-app/update-diagnostic.sh
Executable 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
|
||||
43
bicorder-app/vite.config.ts
Normal file
43
bicorder-app/vite.config.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"name": "Protocol Bicorder",
|
||||
"schema": "bicorder.schema.json",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"description": "A diagnostic tool for the study of protocols",
|
||||
"author": "Nathan Schneider",
|
||||
"date_modified": "YYYY-MM-DD",
|
||||
"date_modified": "2025-11-21",
|
||||
|
||||
"metadata": {
|
||||
"protocol": null,
|
||||
"analyst": null,
|
||||
"standpoint": null,
|
||||
"timestamp": null
|
||||
"timestamp": null,
|
||||
"shortform": false
|
||||
},
|
||||
|
||||
"diagnostic": [
|
||||
@@ -24,7 +25,8 @@
|
||||
"term_right": "implicit",
|
||||
"term_right_description": "The design is not stated explicitly but is learned by participants in another way",
|
||||
"value": null,
|
||||
"citation": null
|
||||
"notes": null,
|
||||
"shortform": false
|
||||
},
|
||||
{
|
||||
"term_left": "precise",
|
||||
@@ -32,15 +34,17 @@
|
||||
"term_right": "interpretive",
|
||||
"term_right_description": "The design is ambiguous, allowing participants a wide range of interpretation",
|
||||
"value": null,
|
||||
"citation": null
|
||||
"notes": null,
|
||||
"shortform": true
|
||||
},
|
||||
{
|
||||
"term_left": "elite",
|
||||
"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": null,
|
||||
"citation": null
|
||||
"notes": null,
|
||||
"shortform": true
|
||||
},
|
||||
{
|
||||
"term_left": "documenting",
|
||||
@@ -48,7 +52,8 @@
|
||||
"term_right": "enabling",
|
||||
"term_right_description": "The primary purpose is to enable activity that might not happen otherwise",
|
||||
"value": null,
|
||||
"citation": null
|
||||
"notes": null,
|
||||
"shortform": false
|
||||
},
|
||||
{
|
||||
"term_left": "static",
|
||||
@@ -56,7 +61,8 @@
|
||||
"term_right": "malleable",
|
||||
"term_right_description": "Designed to be changed by participants according to evolving needs",
|
||||
"value": null,
|
||||
"citation": null
|
||||
"notes": null,
|
||||
"shortform": true
|
||||
},
|
||||
{
|
||||
"term_left": "technical",
|
||||
@@ -64,7 +70,8 @@
|
||||
"term_right": "social",
|
||||
"term_right_description": "Primarily concerned with interactions among people or groups",
|
||||
"value": null,
|
||||
"citation": null
|
||||
"notes": null,
|
||||
"shortform": false
|
||||
},
|
||||
{
|
||||
"term_left": "universal",
|
||||
@@ -72,7 +79,8 @@
|
||||
"term_right": "particular",
|
||||
"term_right_description": "Addressed to a specific community",
|
||||
"value": null,
|
||||
"citation": null
|
||||
"notes": null,
|
||||
"shortform": false
|
||||
},
|
||||
{
|
||||
"term_left": "durable",
|
||||
@@ -80,7 +88,8 @@
|
||||
"term_right": "ephemeral",
|
||||
"term_right_description": "Designed to vanish when no longer needed",
|
||||
"value": null,
|
||||
"citation": null
|
||||
"notes": null,
|
||||
"shortform": false
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -94,7 +103,8 @@
|
||||
"term_right": "micro",
|
||||
"term_right_description": "Operates at small scales with few participants or narrow scope",
|
||||
"value": null,
|
||||
"citation": null
|
||||
"notes": null,
|
||||
"shortform": false
|
||||
},
|
||||
{
|
||||
"term_left": "sovereign",
|
||||
@@ -102,7 +112,8 @@
|
||||
"term_right": "subsidiary",
|
||||
"term_right_description": "An operating logic under the control of a particular entity",
|
||||
"value": null,
|
||||
"citation": null
|
||||
"notes": null,
|
||||
"shortform": true
|
||||
},
|
||||
{
|
||||
"term_left": "self-enforcing",
|
||||
@@ -110,7 +121,8 @@
|
||||
"term_right": "enforced",
|
||||
"term_right_description": "Rules require external enforcement by authorities or institutions",
|
||||
"value": null,
|
||||
"citation": null
|
||||
"notes": null,
|
||||
"shortform": true
|
||||
},
|
||||
{
|
||||
"term_left": "abstract",
|
||||
@@ -118,7 +130,8 @@
|
||||
"term_right": "embodied",
|
||||
"term_right_description": "Participants learn the protocol by physically practicing it",
|
||||
"value": null,
|
||||
"citation": null
|
||||
"notes": null,
|
||||
"shortform": false
|
||||
},
|
||||
{
|
||||
"term_left": "obligatory",
|
||||
@@ -126,7 +139,8 @@
|
||||
"term_right": "voluntary",
|
||||
"term_right_description": "Participation in the protocol is optional and not coerced",
|
||||
"value": null,
|
||||
"citation": null
|
||||
"notes": null,
|
||||
"shortform": true
|
||||
},
|
||||
{
|
||||
"term_left": "flocking",
|
||||
@@ -134,7 +148,8 @@
|
||||
"term_right": "swarming",
|
||||
"term_right_description": "Coordination occurs through distributed interactions without central direction",
|
||||
"value": null,
|
||||
"citation": null
|
||||
"notes": null,
|
||||
"shortform": true
|
||||
},
|
||||
{
|
||||
"term_left": "defensible",
|
||||
@@ -142,7 +157,8 @@
|
||||
"term_right": "exposed",
|
||||
"term_right_description": "Weak boundaries and vulnerable to external influence",
|
||||
"value": null,
|
||||
"citation": null
|
||||
"notes": null,
|
||||
"shortform": false
|
||||
},
|
||||
{
|
||||
"term_left": "exclusive",
|
||||
@@ -150,7 +166,8 @@
|
||||
"term_right": "non-exclusive",
|
||||
"term_right_description": "Does not exclude the use of any other protocols",
|
||||
"value": null,
|
||||
"citation": null
|
||||
"notes": null,
|
||||
"shortform": false
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -164,7 +181,8 @@
|
||||
"term_right": "insufficient",
|
||||
"term_right_description": "Does not, on its own, adequately meet the needs and goals of participants",
|
||||
"value": null,
|
||||
"citation": null
|
||||
"notes": null,
|
||||
"shortform": false
|
||||
},
|
||||
{
|
||||
"term_left": "crystallized",
|
||||
@@ -172,7 +190,8 @@
|
||||
"term_right": "contested",
|
||||
"term_right_description": "Content and meaning are disputed or under debate",
|
||||
"value": null,
|
||||
"citation": null
|
||||
"notes": null,
|
||||
"shortform": false
|
||||
},
|
||||
{
|
||||
"term_left": "trust-evading",
|
||||
@@ -180,7 +199,8 @@
|
||||
"term_right": "trust-inducing",
|
||||
"term_right_description": "Relies on or cultivates trust among participants",
|
||||
"value": null,
|
||||
"citation": null
|
||||
"notes": null,
|
||||
"shortform": false
|
||||
},
|
||||
{
|
||||
"term_left": "predictable",
|
||||
@@ -188,7 +208,8 @@
|
||||
"term_right": "emergent",
|
||||
"term_right_description": "Produces unexpected or novel outcomes",
|
||||
"value": null,
|
||||
"citation": null
|
||||
"notes": null,
|
||||
"shortform": true
|
||||
},
|
||||
{
|
||||
"term_left": "exclusion",
|
||||
@@ -196,7 +217,8 @@
|
||||
"term_right": "inclusion",
|
||||
"term_right_description": "The protocol reduces barriers and includes diverse participants",
|
||||
"value": null,
|
||||
"citation": null
|
||||
"notes": null,
|
||||
"shortform": true
|
||||
},
|
||||
{
|
||||
"term_left": "Kafka",
|
||||
@@ -204,7 +226,8 @@
|
||||
"term_right": "Whitehead",
|
||||
"term_right_description": "Enables participants to carry out desired activities with less work or thought",
|
||||
"value": null,
|
||||
"citation": null
|
||||
"notes": null,
|
||||
"shortform": false
|
||||
},
|
||||
{
|
||||
"term_left": "dead",
|
||||
@@ -212,7 +235,8 @@
|
||||
"term_right": "alive",
|
||||
"term_right_description": "Actively utilized by relevant participants",
|
||||
"value": null,
|
||||
"citation": null
|
||||
"notes": null,
|
||||
"shortform": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -225,8 +249,9 @@
|
||||
"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": null,
|
||||
"citation": null
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"term_left": "polarized",
|
||||
@@ -234,8 +259,19 @@
|
||||
"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": null,
|
||||
"citation": null
|
||||
"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": null,
|
||||
"notes": null
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -20,10 +20,11 @@
|
||||
macro < [|||||||||] > micro
|
||||
sovereign < [|||||||||] > subsidiary
|
||||
self-enforcing < [|||||||||] > enforced
|
||||
analyzed < [|||||||||] > embodied
|
||||
abstract < [|||||||||] > embodied
|
||||
obligatory < [|||||||||] > voluntary
|
||||
flocking < [|||||||||] > swarming
|
||||
defensible < [|||||||||] > exposed
|
||||
exclusive < [|||||||||] > non-exclusive
|
||||
|
||||
EXPERIENCE
|
||||
sufficient < [|||||||||] > insufficient
|
||||
|
||||
Reference in New Issue
Block a user