Compare commits

..

4 Commits

Author SHA1 Message Date
Nathan Schneider c571bf1c01 Add landing screen as the app's entry point
Introduce a simple, show-don't-tell entry screen that lowers the "what
counts as a protocol" barrier and frames the activity as a short journey:

- Lead line "Examine the protocols around you" over a rotating, cross-fading
  list of deliberately diverse examples (traffic, code, kitchens, ritual,
  governance, play)
- Prominent "Begin" CTA above a three-step path: Describe / Understand / Share
- "About the Bicorder" link opens the existing help modal

Shown on first arrival; returning users with a reading in progress skip
straight to the diagnostic (computed synchronously, no flash). Remove the
now-redundant inline description from the focused flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:37:42 -06:00
Nathan Schneider fd556d967f Fix inverted sign in formal/informal LDA analysis gradient
The model (analysis/bicorder_model.json) maps a positive LDA score to
cluster 2 = Institutional/Bureaucratic = "formal", but ldaScoreToScale
added the score (5 + score*4/3), sending formal/institutional protocols
toward 9 (informal) and vice versa. bicorder.json defines this gradient
as 1 = formal, 9 = informal, so the score must be subtracted.

- Flip the sign: value = 5 - (ldaScore * 4/3); correct the doc comment to
  state the model's actual sign convention
- Rename calculateBureaucratic -> calculateFormalInformal and update the
  stale analysisOrder comment, matching bicorder.json's formal/informal terms

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:36:01 -06:00
Nathan Schneider f07708f296 Redesign gradient control as ASCII diverging-fill scale
Replace the click-anywhere ASCII bar (whose hit-mapping was offset from
the displayed marker, so taps registered the wrong value) with a track of
nine discrete, exact tap targets that fill from the center outward to the
selection: [----==#--]. Direction and distance from center read at a glance.

- Exact per-position selection on touch and desktop; no more position math
- Keyboard support (arrows/Home/End), scoped so it doesn't trigger
  the app-level screen navigation
- Center the bar and slim mobile padding so the full track fits narrow
  viewports
- Apply the same control to the manual analysis gradient; render the
  automated ones as a matching read-only, dimmed display
- Add a subtle gray "Value: #" indicator below the buttons on the
  diagnostic gradients, matching the analysis screens
- Move the "auto-calculated" note under the ANALYSIS header instead of
  after the value

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:02:16 -06:00
Nathan Schneider 12ac4eb943 Improve metadata prompts; fix upload error + remove debug log
- Replace bracketed metadata placeholders with prompting questions
- Fix upload error message (response.statusContents -> statusText)
- Remove stray debug console.log on reactive screen updates
- Document the upload token's security model in the app README

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:09:09 -06:00
7 changed files with 631 additions and 271 deletions
+15
View File
@@ -104,6 +104,21 @@ When you click **Upload**, your readings will be:
All uploaded readings are public and available for research and analysis. By uploading, you consent to releasing your diagnostic under a public domain license. All uploaded readings are public and available for research and analysis. By uploading, you consent to releasing your diagnostic under a public domain license.
#### Upload credentials and security model
The upload feature posts directly from the user's browser to the Gitea API using an access token defined in `src/components/ExportControls.svelte` (`GITEA_TOKEN`).
**This token is intentionally embedded in the client bundle, and that is acceptable here.** Because this is a purely static app, the browser must make the API call itself — there is no server-side code of ours to hold the token. Any credential the browser uses is therefore necessarily public; build-time environment variables would still be baked into the shipped JavaScript, so they would offer no real protection. The exposure is contained by *what the token can do* rather than by hiding it:
- The token belongs to a **dedicated `bicorder-bot` account**, not to a personal account.
- `bicorder-bot` is a **collaborator with write access only to the public [`protocol-bicorder-data`](https://git.medlab.host/ntnsndr/protocol-bicorder-data) repo**. It cannot push to any other repository.
- It has **`admin: false`** on that repo, so it can only add/modify files — it cannot delete the repo or change its settings.
- The token scope is `write:repository` (it cannot even read user account details).
The entire worst-case blast radius is therefore: someone extracts the token and spams or vandalizes the contents of the public data repo. This is recoverable (revert the commits) and the repo contains only public submissions.
**If the token is ever abused:** revoke it under the `bicorder-bot` account (Gitea → Settings → Applications → Access Tokens — note that tokens live under the *user account*, not the repo), generate a replacement with the same `write:repository` scope, update `GITEA_TOKEN` in `ExportControls.svelte`, and rebuild. Optionally, enable branch protection on the data repo's `main` branch as an additional safeguard.
## Browser Support ## Browser Support
- Modern browsers (Chrome, Firefox, Safari, Edge) - Modern browsers (Chrome, Firefox, Safari, Edge)
+66 -28
View File
@@ -9,6 +9,7 @@
import FormRecommendation from './components/FormRecommendation.svelte'; import FormRecommendation from './components/FormRecommendation.svelte';
import AnalysisTransitionBanner from './components/AnalysisTransitionBanner.svelte'; import AnalysisTransitionBanner from './components/AnalysisTransitionBanner.svelte';
import HamburgerMenu from './components/HamburgerMenu.svelte'; import HamburgerMenu from './components/HamburgerMenu.svelte';
import Landing from './components/Landing.svelte';
import { BicorderClassifier } from './bicorder-classifier'; import { BicorderClassifier } from './bicorder-classifier';
// Load bicorder data and model from build-time constants // Load bicorder data and model from build-time constants
@@ -30,6 +31,32 @@
let refreshKey = 0; // Used to force component refresh in focused mode let refreshKey = 0; // Used to force component refresh in focused mode
let isHelpOpen = false; let isHelpOpen = false;
// Show the landing screen on first arrival. Returning users who already have
// a reading in progress skip straight to the diagnostic. Computed
// synchronously (not in onMount) so the landing never flashes for them.
function hasReadingInProgress(): boolean {
if (typeof localStorage === 'undefined') return false;
try {
const saved = localStorage.getItem('bicorder-state');
if (!saved) return false;
const s = JSON.parse(saved);
const hasProtocol = !!s?.metadata?.protocol;
const hasValue = s?.diagnostic?.some(
(set: any) => set?.gradients?.some((g: any) => g?.value !== null && g?.value !== undefined)
);
return hasProtocol || hasValue;
} catch {
return false;
}
}
let started = hasReadingInProgress();
function startReading() {
started = true;
currentScreen = 0;
}
// Screen types // Screen types
type Screen = type Screen =
| { type: 'metadata' } | { type: 'metadata' }
@@ -60,8 +87,8 @@
}); });
// Analysis screens (shown in both shortform and longform) // Analysis screens (shown in both shortform and longform)
// Show useful/not-useful gradient first (index 3), then the others // Show the useful gradient first (index 3), then the others
const analysisOrder = [3, 0, 1, 2]; // useful/not-useful, hardness, polarization, bureaucratic const analysisOrder = [3, 0, 1, 2]; // useful, hardness, polarization, formal/informal
analysisOrder.forEach((index) => { analysisOrder.forEach((index) => {
screens.push({ type: 'analysis', index, gradient: data.analysis[index] }); screens.push({ type: 'analysis', index, gradient: data.analysis[index] });
}); });
@@ -77,11 +104,6 @@
$: currentScreenData = screens[currentScreen]; $: currentScreenData = screens[currentScreen];
$: totalScreens = screens.length; $: totalScreens = screens.length;
// Debug: log when screens change
$: if (screens) {
console.log(`Screens updated: ${screens.length} total, shortform: ${data.metadata.shortform}`);
}
function goToNextScreen() { function goToNextScreen() {
if (currentScreen < totalScreens - 1) { if (currentScreen < totalScreens - 1) {
currentScreen++; currentScreen++;
@@ -308,25 +330,30 @@
function ldaScoreToScale(ldaScore: number | null): number | null { function ldaScoreToScale(ldaScore: number | null): number | null {
/** /**
* Convert LDA score to 1-9 scale. * Convert LDA score to the analysis[2] "formal vs informal" 1-9 scale.
* LDA scores typically range from -4 to +4 (8 range) * LDA scores typically range from -4 to +4 (8 range); target is 1-9.
* Target scale is 1 to 9 (8 range)
* *
* Formula: value = 5 + (ldaScore * 4/3) * The model's sign convention (see analysis/bicorder_model.json):
* - LDA -3 or less → 1 (bureaucratic) * positive LDA → cluster 2 = Institutional/Bureaucratic = "formal"
* - LDA 0 → 5 (boundary) * negative LDA → cluster 1 = Relational/Cultural = "informal"
* - LDA +3 or more → 9 (relational) * bicorder.json defines this gradient as 1 = formal, 9 = informal, so a
* positive LDA score must map toward 1 (formal). The score is therefore
* subtracted, not added.
*
* Formula: value = 5 - (ldaScore * 4/3)
* - LDA +3 or more → 1 (formal / institutional / bureaucratic)
* - LDA 0 → 5 (boundary, characteristics of both families)
* - LDA -3 or less → 9 (informal / relational / cultural)
*/ */
if (ldaScore === null) return null; if (ldaScore === null) return null;
// Scale: value = 5 + (ldaScore * 1.33) const value = 5 - (ldaScore * 4.0 / 3.0);
const value = 5 + (ldaScore * 4.0 / 3.0);
// Clamp to 1-9 range and round // Clamp to 1-9 range and round
return Math.round(Math.max(1, Math.min(9, value))); return Math.round(Math.max(1, Math.min(9, value)));
} }
function calculateBureaucratic(): number | null { function calculateFormalInformal(): number | null {
// Collect all diagnostic gradients with their set and gradient info // Collect all diagnostic gradients with their set and gradient info
const ratings: Record<string, number> = {}; const ratings: Record<string, number> = {};
@@ -334,7 +361,7 @@
const setName = diagnosticSet.set_name; const setName = diagnosticSet.set_name;
diagnosticSet.gradients.forEach((gradient) => { diagnosticSet.gradients.forEach((gradient) => {
if (gradient.value !== null) { if (gradient.value !== null) {
// Create dimension name in format: SetName_left_vs_right // Dimension name must match the model's keys: SetName_left_vs_right
const dimensionName = `${setName}_${gradient.term_left}_vs_${gradient.term_right}`; const dimensionName = `${setName}_${gradient.term_left}_vs_${gradient.term_right}`;
ratings[dimensionName] = gradient.value; ratings[dimensionName] = gradient.value;
} }
@@ -348,10 +375,10 @@
// Get prediction from classifier (need detailed: true to get ldaScore) // Get prediction from classifier (need detailed: true to get ldaScore)
const result = classifier.predict(ratings, { detailed: true }); const result = classifier.predict(ratings, { detailed: true });
// Convert LDA score to 1-9 scale // Convert LDA score to the 1-9 formal/informal scale
return ldaScoreToScale(result.ldaScore); return ldaScoreToScale(result.ldaScore);
} catch (error) { } catch (error) {
console.error('Error calculating bureaucratic/relational score:', error); console.error('Error calculating formal/informal score:', error);
return null; return null;
} }
} }
@@ -367,8 +394,8 @@
// Polarized/Centrist // Polarized/Centrist
data.analysis[1].value = calculatePolarization(); data.analysis[1].value = calculatePolarization();
} else if (index === 2) { } else if (index === 2) {
// Bureaucratic/Relational (LDA classifier) // Formal/Informal (LDA classifier)
data.analysis[2].value = calculateBureaucratic(); data.analysis[2].value = calculateFormalInformal();
} }
} }
}); });
@@ -432,6 +459,9 @@
<HelpModal bind:isOpen={isHelpOpen} /> <HelpModal bind:isOpen={isHelpOpen} />
{#if !started}
<Landing on:begin={startReading} on:about={openHelp} />
{:else}
<main> <main>
<div class="header"> <div class="header">
<div class="header-left"> <div class="header-left">
@@ -519,12 +549,6 @@
{:else} {:else}
<!-- FOCUSED MODE: Show one screen at a time --> <!-- FOCUSED MODE: Show one screen at a time -->
{#if currentScreen === 0}
<div class="description">
<p>A diagnostic tool for the study of protocols</p>
</div>
{/if}
<div class="focused-container"> <div class="focused-container">
{#if currentScreenData.type === 'metadata'} {#if currentScreenData.type === 'metadata'}
<div class="focused-screen"> <div class="focused-screen">
@@ -574,6 +598,10 @@
<div class="focused-screen gradient-screen"> <div class="focused-screen gradient-screen">
<div class="screen-category">ANALYSIS</div> <div class="screen-category">ANALYSIS</div>
{#if screen.gradient.automated}
<div class="analysis-auto-note">auto-calculated</div>
{/if}
{#if isFirstAnalysisScreen} {#if isFirstAnalysisScreen}
<AnalysisTransitionBanner <AnalysisTransitionBanner
recommendation={formRecommendation} recommendation={formRecommendation}
@@ -675,6 +703,7 @@
</div> </div>
{/if} {/if}
</main> </main>
{/if}
<style> <style>
main { main {
@@ -825,6 +854,15 @@
opacity: 0.6; opacity: 0.6;
} }
.analysis-auto-note {
text-align: center;
color: #888888;
font-size: 0.85rem;
font-style: italic;
margin-top: -1.5rem;
margin-bottom: 2rem;
}
.gradient-focused { .gradient-focused {
padding: 0; padding: 0;
display: flex; display: flex;
+180 -132
View File
@@ -11,8 +11,39 @@
notes: string; notes: string;
}>(); }>();
const scaleValues = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const CENTER = 5;
let showNotes = false; let showNotes = false;
let notesText = gradient.notes || ''; let notesText = gradient.notes || '';
let scaleEl: HTMLDivElement;
// Shared with GradientSlider: the track fills from the center (=) out to
// the chosen position (#), so distance from the middle reads at a glance.
type CellKind = 'empty' | 'center' | 'fill' | 'marker';
function cellKind(cell: number, value: number | null): CellKind {
if (value === null) return cell === CENTER ? 'center' : 'empty';
if (cell === value) return 'marker';
if (value > CENTER && cell >= CENTER && cell < value) return 'fill';
if (value < CENTER && cell > value && cell <= CENTER) return 'fill';
return 'empty';
}
function cellGlyph(kind: CellKind): string {
switch (kind) {
case 'marker': return '#';
case 'fill': return '=';
case 'center': return '+';
default: return '-';
}
}
function selectValue(value: number) {
if (gradient.automated) return;
dispatch('change', value);
scaleEl?.focus();
}
function handleNotApplicable() { function handleNotApplicable() {
dispatch('change', null); dispatch('change', null);
@@ -23,82 +54,35 @@
showNotes = false; showNotes = false;
} }
function handleScaleClick(event: MouseEvent) { function handleKeydown(event: KeyboardEvent) {
if (gradient.automated) return; if (gradient.automated) return;
const target = event.currentTarget as HTMLElement; const current = gradient.value;
const rect = target.getBoundingClientRect(); let next: number | null = null;
const x = event.clientX - rect.left;
const width = rect.width;
// Account for button padding to get actual text area if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') {
const styles = window.getComputedStyle(target); next = current === null ? CENTER : Math.max(1, current - 1);
const paddingLeft = parseFloat(styles.paddingLeft); } else if (event.key === 'ArrowRight' || event.key === 'ArrowUp') {
const paddingRight = parseFloat(styles.paddingRight); next = current === null ? CENTER : Math.min(9, current + 1);
} else if (event.key === 'Home') {
const contentWidth = width - paddingLeft - paddingRight; next = 1;
const xInContent = x - paddingLeft; } else if (event.key === 'End') {
next = 9;
// Clamp to content area and get ratio } else {
const ratio = Math.max(0, Math.min(1, xInContent / contentWidth)); return;
// 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(); event.preventDefault();
const target = event.currentTarget as HTMLElement; event.stopPropagation();
const rect = target.getBoundingClientRect(); dispatch('change', next);
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> </script>
<div class="analysis-gradient"> <div class="analysis-gradient">
{#if gradient.automated && !focusedMode}
<div class="auto-note">auto-calculated</div>
{/if}
<div class="gradient-row" class:focused={focusedMode}> <div class="gradient-row" class:focused={focusedMode}>
{#if !focusedMode} {#if !focusedMode}
<div class="term left"> <div class="term left">
@@ -109,22 +93,53 @@
{/if} {/if}
<div class="bar-container"> <div class="bar-container">
<button {#if gradient.automated}
class="bar" <!-- Read-only display of the auto-calculated value -->
class:interactive={!gradient.automated} <div
class:automated={gradient.automated} class="scale automated"
on:click={handleScaleClick} role="img"
on:touchstart={handleScaleTouch} aria-label="Analysis showing value {gradient.value ?? 'not calculated'}"
role={gradient.automated ? 'img' : 'slider'} title="Auto-calculated"
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} <span class="bracket" aria-hidden="true">[</span>
{#each scaleValues as n}
{@const kind = cellKind(n, gradient.value)}
<span class="cell {kind}">{cellGlyph(kind)}</span>
{/each}
<span class="bracket" aria-hidden="true">]</span>
</div>
{:else}
<!-- Interactive scale (same control as the diagnostic gradients) -->
<div
class="scale"
bind:this={scaleEl}
role="slider"
tabindex="0"
aria-label="Gradient scale between {gradient.term_left} and {gradient.term_right}"
aria-valuemin="1"
aria-valuemax="9"
aria-valuenow={gradient.value}
aria-valuetext={gradient.value === null ? 'Not set' : String(gradient.value)}
on:keydown={handleKeydown}
>
<span class="bracket" aria-hidden="true">[</span>
{#each scaleValues as n}
{@const kind = cellKind(n, gradient.value)}
<button
type="button"
class="cell {kind}"
tabindex="-1"
aria-label="Set value {n}"
aria-pressed={gradient.value === n}
title="{n}"
on:click={() => selectValue(n)}
>
{cellGlyph(kind)}
</button> </button>
{/each}
<span class="bracket" aria-hidden="true">]</span>
</div>
{/if}
</div> </div>
{#if !focusedMode} {#if !focusedMode}
@@ -136,13 +151,6 @@
{/if} {/if}
</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"> <div class="controls">
{#if !gradient.automated} {#if !gradient.automated}
<div class="button-row"> <div class="button-row">
@@ -176,6 +184,10 @@
{/if} {/if}
</div> </div>
<div class="value-display">
Value: {gradient.value !== null ? gradient.value : ''}
</div>
{#if showNotes} {#if showNotes}
<div class="notes-editor"> <div class="notes-editor">
<textarea <textarea
@@ -210,15 +222,6 @@
justify-content: center; justify-content: center;
} }
.gradient-row.focused .bar-container {
width: 100%;
}
.gradient-row.focused .bar {
width: 100%;
min-width: 0;
}
.term { .term {
text-align: center; text-align: center;
font-size: 0.9rem; font-size: 0.9rem;
@@ -232,59 +235,99 @@
text-align: left; text-align: left;
} }
/* The bar is always centered within its container, on every screen size. */
.bar-container { .bar-container {
display: flex; display: flex;
justify-content: center; justify-content: center;
} }
.bar { .scale {
display: inline-flex;
align-items: stretch;
font-family: 'Courier New', Courier, monospace; font-family: 'Courier New', Courier, monospace;
font-size: 1.8rem; font-size: 1.8rem;
white-space: nowrap; line-height: 1;
letter-spacing: 0.2rem;
padding: 0.5rem 1rem;
user-select: none; user-select: none;
min-width: 240px; padding: 0.25rem 0.4rem;
text-align: center; border: 2px solid transparent;
background-color: var(--bg-color); border-radius: 3px;
color: var(--fg-color);
border: 2px solid var(--border-color);
} }
.bar.automated { .scale:focus-visible {
cursor: default; outline: none;
opacity: 0.6;
}
.bar.interactive {
cursor: pointer;
transition: border-color 0.2s;
}
.bar.interactive:hover {
border-color: var(--hover-color); border-color: var(--hover-color);
} }
.bar.interactive:active { .scale.automated {
background-color: var(--input-bg); opacity: 0.6;
border-color: var(--fg-color); cursor: default;
}
.bracket {
display: flex;
align-items: center;
opacity: 0.45;
}
.cell {
font-family: inherit;
font-size: inherit;
line-height: 1;
background: transparent;
color: var(--fg-color);
border: none;
padding: 0.35rem 0.3rem;
min-width: auto;
min-height: auto;
cursor: pointer;
transition: opacity 0.15s, color 0.15s;
}
/* Read-only cells in the automated display are not buttons */
span.cell {
display: flex;
align-items: center;
cursor: default;
}
/* Opacity tiers reinforce the gradient feel: faint track, brighter
fill, fully solid marker. */
.cell.empty {
opacity: 0.35;
}
.cell.center {
opacity: 0.55;
}
.cell.fill {
opacity: 0.75;
}
.cell.marker {
opacity: 1;
font-weight: bold;
}
button.cell:hover {
opacity: 1;
color: var(--hover-color);
background: transparent;
} }
.value-display { .value-display {
text-align: center; text-align: center;
margin: 0.5rem 0; margin-top: 0.75rem;
font-size: 0.9rem; font-size: 0.9rem;
color: #888888;
} }
.analysis-gradient .value-display { .auto-note {
margin-top: 0.5rem; text-align: center;
margin-bottom: 0.25rem; color: #888888;
} font-size: 0.85rem;
.auto-label {
opacity: 0.6;
font-size: 0.8rem;
font-style: italic; font-style: italic;
margin-bottom: 0.5rem;
} }
.controls { .controls {
@@ -383,10 +426,15 @@
grid-row: 3; grid-row: 3;
} }
.bar { .scale {
font-size: 1.4rem; font-size: 1.5rem;
min-width: 200px; padding: 0.25rem 0.2rem;
padding: 0.5rem; }
/* Slim horizontal padding keeps the full track inside narrow
viewports; vertical padding preserves a tall tap target. */
.cell {
padding: 0.5rem 0.2rem;
} }
} }
</style> </style>
@@ -83,7 +83,7 @@
showUploadDialog = false; showUploadDialog = false;
} else { } else {
const errorData = await response.json(); const errorData = await response.json();
throw new Error(errorData.message || `Upload failed: ${response.statusContents}`); throw new Error(errorData.message || `Upload failed: ${response.statusText}`);
} }
} catch (err) { } catch (err) {
console.error('Upload error:', err); console.error('Upload error:', err);
+143 -100
View File
@@ -11,8 +11,40 @@
notes: string; notes: string;
}>(); }>();
const scaleValues = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const CENTER = 5;
let showNotes = false; let showNotes = false;
let notesText = gradient.notes || ''; let notesText = gradient.notes || '';
let scaleEl: HTMLDivElement;
// Glyph shown in each cell, given the current value.
// The track fills from the center (=) out to the chosen position (#),
// so direction and distance from the middle read at a glance.
type CellKind = 'empty' | 'center' | 'fill' | 'marker';
function cellKind(cell: number, value: number | null): CellKind {
if (value === null) return cell === CENTER ? 'center' : 'empty';
if (cell === value) return 'marker';
if (value > CENTER && cell >= CENTER && cell < value) return 'fill';
if (value < CENTER && cell > value && cell <= CENTER) return 'fill';
return 'empty';
}
function cellGlyph(kind: CellKind): string {
switch (kind) {
case 'marker': return '#';
case 'fill': return '=';
case 'center': return '+';
default: return '-';
}
}
function selectValue(value: number) {
dispatch('change', value);
// Keep focus on the scale so arrow keys continue to adjust the value
scaleEl?.focus();
}
function handleNotApplicable() { function handleNotApplicable() {
dispatch('change', null); dispatch('change', null);
@@ -23,75 +55,28 @@
showNotes = false; showNotes = false;
} }
function handleScaleClick(event: MouseEvent) { function handleKeydown(event: KeyboardEvent) {
const target = event.currentTarget as HTMLElement; const current = gradient.value;
const rect = target.getBoundingClientRect(); let next: number | null = null;
const x = event.clientX - rect.left;
const width = rect.width;
// Account for button padding to get actual text area if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') {
const styles = window.getComputedStyle(target); next = current === null ? CENTER : Math.max(1, current - 1);
const paddingLeft = parseFloat(styles.paddingLeft); } else if (event.key === 'ArrowRight' || event.key === 'ArrowUp') {
const paddingRight = parseFloat(styles.paddingRight); next = current === null ? CENTER : Math.min(9, current + 1);
} else if (event.key === 'Home') {
const contentWidth = width - paddingLeft - paddingRight; next = 1;
const xInContent = x - paddingLeft; } else if (event.key === 'End') {
next = 9;
// Clamp to content area and get ratio } else {
const ratio = Math.max(0, Math.min(1, xInContent / contentWidth)); return;
// 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) { // Handle the key here and keep it from bubbling to the app-level
// screen navigation (which also listens for arrow keys).
event.preventDefault(); event.preventDefault();
const target = event.currentTarget as HTMLElement; event.stopPropagation();
const rect = target.getBoundingClientRect(); dispatch('change', next);
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 {
// Slider-style visualization with brackets and value number
if (value === null) {
return '[----+----]';
}
// Value is 1-9, show the number at its position along the slider
const bars = [
'[1--------]', // value 1
'[-2-------]', // value 2
'[--3------]', // value 3
'[---4-----]', // value 4
'[----5----]', // value 5 (centered)
'[-----6---]', // value 6
'[------7--]', // value 7
'[-------8-]', // value 8
'[--------9]' // value 9
];
return bars[value - 1];
}
$: barDisplay = renderBar(gradient.value);
</script> </script>
<div class="gradient-container"> <div class="gradient-container">
@@ -105,19 +90,35 @@
{/if} {/if}
<div class="bar-container"> <div class="bar-container">
<button <div
class="bar" class="scale"
on:click={handleScaleClick} bind:this={scaleEl}
on:touchstart={handleScaleTouch}
role="slider" role="slider"
tabindex="0"
aria-label="Gradient scale between {gradient.term_left} and {gradient.term_right}" aria-label="Gradient scale between {gradient.term_left} and {gradient.term_right}"
aria-valuemin="1" aria-valuemin="1"
aria-valuemax="9" aria-valuemax="9"
aria-valuenow={gradient.value} aria-valuenow={gradient.value}
title="Click or tap on the scale to set value" aria-valuetext={gradient.value === null ? 'Not set' : String(gradient.value)}
on:keydown={handleKeydown}
> >
{barDisplay} <span class="bracket" aria-hidden="true">[</span>
{#each scaleValues as n}
{@const kind = cellKind(n, gradient.value)}
<button
type="button"
class="cell {kind}"
tabindex="-1"
aria-label="Set value {n}"
aria-pressed={gradient.value === n}
title="{n}"
on:click={() => selectValue(n)}
>
{cellGlyph(kind)}
</button> </button>
{/each}
<span class="bracket" aria-hidden="true">]</span>
</div>
</div> </div>
{#if !focusedMode} {#if !focusedMode}
@@ -151,6 +152,10 @@
</div> </div>
</div> </div>
<div class="value-display">
Value: {gradient.value !== null ? gradient.value : ''}
</div>
{#if showNotes} {#if showNotes}
<div class="notes-editor"> <div class="notes-editor">
<textarea <textarea
@@ -185,15 +190,6 @@
justify-content: center; justify-content: center;
} }
.gradient-row.focused .bar-container {
width: 100%;
}
.gradient-row.focused .bar {
width: 100%;
min-width: 0;
}
.term { .term {
text-align: center; text-align: center;
font-size: 0.9rem; font-size: 0.9rem;
@@ -207,37 +203,79 @@
text-align: left; text-align: left;
} }
/* The bar is always centered within its container, on every screen size. */
.bar-container { .bar-container {
display: flex; display: flex;
justify-content: center; justify-content: center;
} }
.bar { .scale {
display: inline-flex;
align-items: stretch;
font-family: 'Courier New', Courier, monospace; font-family: 'Courier New', Courier, monospace;
font-size: 1.8rem; font-size: 1.8rem;
white-space: nowrap; line-height: 1;
letter-spacing: 0.2rem;
padding: 0.5rem 1rem;
cursor: pointer;
user-select: none; user-select: none;
min-width: 240px; padding: 0.25rem 0.4rem;
text-align: center; border: 2px solid transparent;
background-color: var(--bg-color); border-radius: 3px;
color: var(--fg-color);
border: 2px solid var(--border-color);
transition: border-color 0.2s;
} }
.bar:hover { .scale:focus-visible {
outline: none;
border-color: var(--hover-color); border-color: var(--hover-color);
background-color: var(--bg-color);
color: var(--fg-color);
} }
.bar:active { .bracket {
background-color: var(--input-bg); display: flex;
border-color: var(--fg-color); align-items: center;
opacity: 0.45;
}
.cell {
font-family: inherit;
font-size: inherit;
line-height: 1;
background: transparent;
color: var(--fg-color); color: var(--fg-color);
border: none;
padding: 0.35rem 0.3rem;
min-width: auto;
min-height: auto;
cursor: pointer;
transition: opacity 0.15s, color 0.15s;
}
/* Opacity tiers reinforce the gradient feel: faint track, brighter
fill, fully solid marker. */
.cell.empty {
opacity: 0.35;
}
.cell.center {
opacity: 0.55;
}
.cell.fill {
opacity: 0.75;
}
.cell.marker {
opacity: 1;
font-weight: bold;
}
.cell:hover {
opacity: 1;
color: var(--hover-color);
background: transparent;
}
.value-display {
text-align: center;
margin-top: 0.75rem;
font-size: 0.9rem;
color: #888888;
} }
.controls { .controls {
@@ -336,10 +374,15 @@
grid-row: 3; grid-row: 3;
} }
.bar { .scale {
font-size: 1.4rem; font-size: 1.5rem;
min-width: 200px; padding: 0.25rem 0.2rem;
padding: 0.5rem; }
/* Slim horizontal padding keeps the full track inside narrow
viewports; vertical padding preserves a tall tap target. */
.cell {
padding: 0.5rem 0.2rem;
} }
} }
</style> </style>
+216
View File
@@ -0,0 +1,216 @@
<script lang="ts">
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
import { fade } from 'svelte/transition';
const dispatch = createEventDispatcher<{ begin: void; about: void }>();
// A deliberately wide-ranging list — traffic, code, kitchens, ritual,
// governance, play — to show how capacious "protocol" really is.
const examples = [
'a four-way stop',
'tipping at a restaurant',
"Robert's Rules of Order",
'a sourdough starter passed between neighbors',
'code review on a pull request',
'a Quaker meeting for worship',
'queuing for the bus',
'how Wikipedia settles an edit war',
'a family holiday gift exchange',
'the handshake',
'academic peer review',
'trick-or-treating',
'a potluck dinner',
'jury deliberation',
"a city's recycling rules",
'an open-source contributor guide',
];
let idx = 0;
let timer: ReturnType<typeof setInterval>;
onMount(() => {
timer = setInterval(() => {
idx = (idx + 1) % examples.length;
}, 2400);
});
onDestroy(() => clearInterval(timer));
</script>
<div class="landing">
<div class="title-block">
<div class="title-sm">Protocol</div>
<div class="title-lg">BICORDER</div>
<div class="motif" aria-hidden="true">[----#----]</div>
</div>
<div class="scan">
<span class="scan-label">Examine the protocols around you</span>
<span class="scan-example">
{#key idx}
<span in:fade={{ duration: 350 }}>{examples[idx]}</span>
{/key}
</span>
</div>
<button class="begin-btn" on:click={() => dispatch('begin')}>
Begin &rarr;
</button>
<ol class="steps">
<li>
<span class="step-n">1</span>
<span class="step-text"><strong>Describe</strong> a protocol around you</span>
</li>
<li>
<span class="step-n">2</span>
<span class="step-text"><strong>Understand</strong> how it works</span>
</li>
<li>
<span class="step-n">3</span>
<span class="step-text"><strong>Share</strong> your findings</span>
</li>
</ol>
<button class="about-link" on:click={() => dispatch('about')}>
About the Bicorder
</button>
</div>
<style>
.landing {
min-height: 100vh;
max-width: 640px;
margin: 0 auto;
padding: 2rem 1.5rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 2rem;
}
.title-block {
display: flex;
flex-direction: column;
align-items: center;
}
.title-sm {
font-size: 1.1rem;
letter-spacing: 0.3rem;
opacity: 0.7;
}
.title-lg {
font-size: 2.4rem;
font-weight: bold;
letter-spacing: 0.4rem;
}
.motif {
font-family: 'Courier New', Courier, monospace;
font-size: 1.2rem;
letter-spacing: 0.15rem;
opacity: 0.4;
margin-top: 0.75rem;
}
.scan {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.scan-label {
font-size: 1.4rem;
font-weight: bold;
line-height: 1.4;
}
.scan-example {
/* Reserve space for up to two wrapped lines so the rotating example
never shifts the steps below it. */
display: flex;
align-items: flex-start;
justify-content: center;
min-height: 3.2rem;
font-size: 1.2rem;
opacity: 0.85;
}
.steps {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.85rem;
text-align: left;
}
.steps li {
display: flex;
align-items: center;
gap: 0.85rem;
font-size: 1.05rem;
}
.step-n {
flex-shrink: 0;
width: 1.9rem;
height: 1.9rem;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid var(--border-color);
border-radius: 50%;
font-weight: bold;
font-size: 0.95rem;
}
.step-text {
opacity: 0.9;
}
.begin-btn {
font-size: 1.15rem;
font-weight: bold;
letter-spacing: 0.1rem;
padding: 0.85rem 2.5rem;
}
.about-link {
background: none;
border: none;
min-height: auto;
min-width: auto;
padding: 0;
font-size: 0.85rem;
text-decoration: underline;
opacity: 0.55;
cursor: pointer;
}
.about-link:hover {
opacity: 0.9;
background: none;
color: var(--fg-color);
}
@media (max-width: 768px) {
.landing {
gap: 1.5rem;
padding: 1.5rem 1.25rem;
}
.title-lg {
font-size: 2rem;
}
.scan-label {
font-size: 1.2rem;
}
}
</style>
@@ -36,7 +36,7 @@
<input <input
id="protocol" id="protocol"
type="text" type="text"
placeholder="[Protocol]" placeholder="What protocol are you analyzing?"
value={metadata.protocol || ''} value={metadata.protocol || ''}
on:input={(e) => handleInput('protocol', e.currentTarget.value)} on:input={(e) => handleInput('protocol', e.currentTarget.value)}
/> />
@@ -46,7 +46,7 @@
<label for="description">Description:</label> <label for="description">Description:</label>
<textarea <textarea
id="description" id="description"
placeholder="[Description]" placeholder="Briefly, what is this protocol?"
value={metadata.description || ''} value={metadata.description || ''}
on:input={(e) => handleInput('description', e.currentTarget.value)} on:input={(e) => handleInput('description', e.currentTarget.value)}
rows="3" rows="3"
@@ -58,7 +58,7 @@
<input <input
id="analyst" id="analyst"
type="text" type="text"
placeholder="[Analyst]" placeholder="How do you identify yourself?"
value={metadata.analyst || ''} value={metadata.analyst || ''}
on:input={(e) => handleInput('analyst', e.currentTarget.value)} on:input={(e) => handleInput('analyst', e.currentTarget.value)}
/> />
@@ -69,7 +69,7 @@
<input <input
id="standpoint" id="standpoint"
type="text" type="text"
placeholder="[Standpoint]" placeholder="What is your relationship to the protocol?"
value={metadata.standpoint || ''} value={metadata.standpoint || ''}
on:input={(e) => handleInput('standpoint', e.currentTarget.value)} on:input={(e) => handleInput('standpoint', e.currentTarget.value)}
/> />