Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c571bf1c01 | |||
| fd556d967f | |||
| f07708f296 | |||
| 12ac4eb943 |
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 →
|
||||||
|
</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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user