Compare commits

...

4 Commits

Author SHA1 Message Date
Nathan Schneider
0e437d2b88 Added Focused mode as default. Also finally adding the icons correctly. 2025-11-29 20:19:08 -05:00
Nathan Schneider
bcc6727917 Better fix on mobile tooltips 2025-11-29 17:10:02 -05:00
Nathan Schneider
af52f32330 Created icon for PWA and web 2025-11-29 17:05:15 -05:00
Nathan Schneider
d278e8998a Fixed mobile tooltip positioning on mobile 2025-11-29 16:57:07 -05:00
12 changed files with 729 additions and 90 deletions

View File

@@ -2,11 +2,12 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" sizes="192x192" href="/icon-192.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" /> <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="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="#1a1a2e" />
<meta name="theme-color" content="#000000" media="(prefers-color-scheme: dark)" />
<title>Protocol Bicorder</title> <title>Protocol Bicorder</title>
</head> </head>
<body> <body>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 B

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1a1a2e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#16213e;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background -->
<rect width="128" height="128" fill="url(#bgGrad)" rx="16"/>
<!-- Large hash symbol -->
<text x="64" y="95" font-family="'Courier New', monospace" font-size="100" font-weight="bold" fill="#bbe1fa" text-anchor="middle">#</text>
</svg>

After

Width:  |  Height:  |  Size: 577 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,11 +1,47 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="100" height="100" fill="#ffffff"/> <defs>
<text x="50" y="35" font-family="monospace" font-size="16" fill="#000000" text-anchor="middle"> <linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
Protocol <stop offset="0%" style="stop-color:#1a1a2e;stop-opacity:1" />
</text> <stop offset="100%" style="stop-color:#16213e;stop-opacity:1" />
<text x="50" y="55" font-family="monospace" font-size="20" fill="#000000" text-anchor="middle" font-weight="bold"> </linearGradient>
BICORDER <linearGradient id="accentGradient" x1="0%" y1="0%" x2="100%" y2="100%">
</text> <stop offset="0%" style="stop-color:#0f4c75;stop-opacity:1" />
<rect x="20" y="65" width="60" height="10" fill="none" stroke="#000000" stroke-width="2"/> <stop offset="100%" style="stop-color:#3282b8;stop-opacity:1" />
<rect x="22" y="67" width="20" height="6" fill="#000000"/> </linearGradient>
</defs>
<!-- Background -->
<rect width="512" height="512" fill="url(#bgGradient)" rx="80"/>
<!-- Outer frame -->
<rect x="64" y="64" width="384" height="384" fill="none" stroke="url(#accentGradient)" stroke-width="8" rx="20"/>
<!-- Bicorder pattern "|#|" in monospace style -->
<g font-family="'Courier New', monospace" font-weight="bold" text-anchor="middle">
<!-- Left bar -->
<text x="180" y="300" font-size="180" fill="#3282b8">|</text>
<!-- Center hash -->
<text x="256" y="300" font-size="180" fill="#bbe1fa">#</text>
<!-- Right bar -->
<text x="332" y="300" font-size="180" fill="#3282b8">|</text>
</g>
<!-- Decorative scan lines -->
<line x1="96" y1="140" x2="416" y2="140" stroke="#3282b8" stroke-width="2" opacity="0.3"/>
<line x1="96" y1="372" x2="416" y2="372" stroke="#3282b8" stroke-width="2" opacity="0.3"/>
<!-- Corner accents -->
<rect x="80" y="80" width="30" height="4" fill="#bbe1fa" opacity="0.8"/>
<rect x="80" y="80" width="4" height="30" fill="#bbe1fa" opacity="0.8"/>
<rect x="402" y="80" width="30" height="4" fill="#bbe1fa" opacity="0.8"/>
<rect x="428" y="80" width="4" height="30" fill="#bbe1fa" opacity="0.8"/>
<rect x="80" y="428" width="30" height="4" fill="#bbe1fa" opacity="0.8"/>
<rect x="80" y="402" width="4" height="30" fill="#bbe1fa" opacity="0.8"/>
<rect x="402" y="428" width="30" height="4" fill="#bbe1fa" opacity="0.8"/>
<rect x="428" y="402" width="4" height="30" fill="#bbe1fa" opacity="0.8"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 535 B

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -14,6 +14,86 @@
data.metadata.timestamp = new Date().toISOString(); data.metadata.timestamp = new Date().toISOString();
} }
// View mode and navigation state
type ViewMode = 'focused' | 'list';
let viewMode: ViewMode = 'focused'; // Focused is default
let currentScreen = 0;
let refreshKey = 0; // Used to force component refresh in focused mode
// Screen types
type Screen =
| { type: 'metadata' }
| { type: 'gradient'; setIndex: number; gradientIndex: number; gradient: Gradient; setName: string }
| { type: 'analysis'; index: number; gradient: AnalysisGradient }
| { type: 'export' };
// Calculate all screens based on current shortform setting
function calculateScreens(): Screen[] {
const screens: Screen[] = [];
// Metadata screen
screens.push({ type: 'metadata' });
// Diagnostic gradient screens
data.diagnostic.forEach((diagnosticSet, setIndex) => {
diagnosticSet.gradients.forEach((gradient, gradientIndex) => {
if (!data.metadata.shortform || gradient.shortform) {
screens.push({
type: 'gradient',
setIndex,
gradientIndex,
gradient,
setName: diagnosticSet.set_name
});
}
});
});
// Analysis screens (not in shortform)
if (!data.metadata.shortform) {
data.analysis.forEach((gradient, index) => {
screens.push({ type: 'analysis', index, gradient });
});
}
// Export screen
screens.push({ type: 'export' });
return screens;
}
$: screens = calculateScreens();
$: currentScreenData = screens[currentScreen];
$: totalScreens = screens.length;
function goToNextScreen() {
if (currentScreen < totalScreens - 1) {
currentScreen++;
}
}
function goToPrevScreen() {
if (currentScreen > 0) {
currentScreen--;
}
}
function toggleViewMode() {
viewMode = viewMode === 'focused' ? 'list' : 'focused';
}
// Generate ASCII progress bar
function generateProgressBar(current: number, total: number): string {
const filled = '#';
const empty = '-';
const barLength = Math.min(total, 20); // Cap at 20 characters for display
const filledCount = Math.round((current / total) * barLength);
const emptyCount = barLength - filledCount;
return filled.repeat(filledCount) + empty.repeat(emptyCount);
}
$: progressBar = generateProgressBar(currentScreen + 1, totalScreens);
// Load saved state from localStorage // Load saved state from localStorage
onMount(() => { onMount(() => {
const saved = localStorage.getItem('bicorder-state'); const saved = localStorage.getItem('bicorder-state');
@@ -45,6 +125,27 @@
console.error('Failed to load saved state:', e); console.error('Failed to load saved state:', e);
} }
} }
// Keyboard navigation for focused mode
function handleKeyDown(e: KeyboardEvent) {
if (viewMode !== 'focused') return;
// Only navigate if not typing in an input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
goToNextScreen();
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
goToPrevScreen();
}
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}); });
// Save state whenever it changes // Save state whenever it changes
@@ -117,57 +218,181 @@
<div class="header"> <div class="header">
<div class="title">Protocol</div> <div class="title">Protocol</div>
<div class="title">BICORDER</div> <div class="title">BICORDER</div>
<button class="mode-toggle" on:click={toggleViewMode} aria-label="Toggle view mode">
{viewMode === 'focused' ? '☰' : '⊡'}
</button>
</div> </div>
<MetadataFields {#if viewMode === 'list'}
metadata={data.metadata} <!-- LIST MODE: Show all sections -->
on:update={handleMetadataUpdate} <MetadataFields
/> metadata={data.metadata}
on:update={handleMetadataUpdate}
/>
{#each data.diagnostic as diagnosticSet, setIndex} {#each data.diagnostic as diagnosticSet, setIndex}
<section class="diagnostic-set"> <section class="diagnostic-set">
<div class="set-header">{diagnosticSet.set_name.toUpperCase()}</div> <div class="set-header">{diagnosticSet.set_name.toUpperCase()}</div>
<div class="set-description">{diagnosticSet.set_description}</div> <div class="set-description">{diagnosticSet.set_description}</div>
{#each diagnosticSet.gradients as gradient, gradientIndex} {#each diagnosticSet.gradients as gradient, gradientIndex}
{#if !data.metadata.shortform || gradient.shortform} {#if !data.metadata.shortform || gradient.shortform}
<GradientSlider <GradientSlider
{gradient} {gradient}
on:change={(e) => { on:change={(e) => {
data.diagnostic[setIndex].gradients[gradientIndex].value = e.detail ?? null; 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; data = data;
}} }
on:notes={(e) => { }}
data.diagnostic[setIndex].gradients[gradientIndex].notes = e.detail; on:notes={(e) => {
data = data; data.analysis[index].notes = e.detail;
}} data = data;
/> }}
{/if} />
{/each} {/each}
</section> </section>
{/each}
<section class="analysis-section"> <ExportControls {data} on:reset={handleReset} />
<div class="set-header">ANALYSIS</div>
{#each data.analysis as analysisItem, index} {:else}
<AnalysisDisplay <!-- FOCUSED MODE: Show one screen at a time -->
gradient={analysisItem} <div class="focused-container">
on:change={(e) => { {#if currentScreenData.type === 'metadata'}
if (!analysisItem.automated) { <div class="focused-screen">
data.analysis[index].value = e.detail ?? null; <MetadataFields
data = data; metadata={data.metadata}
} on:update={handleMetadataUpdate}
}} />
on:notes={(e) => { </div>
data.analysis[index].notes = e.detail;
data = data;
}}
/>
{/each}
</section>
<ExportControls {data} on:reset={handleReset} /> {:else if currentScreenData.type === 'gradient'}
{@const screen = currentScreenData}
<div class="focused-screen gradient-screen">
<div class="screen-category">{screen.setName.toUpperCase()}</div>
<div class="gradient-focused">
<div class="term-desc left-desc">
<div class="term-name">{screen.gradient.term_left}</div>
<div class="description-text">{screen.gradient.term_left_description}</div>
</div>
{#key refreshKey}
<GradientSlider
gradient={screen.gradient}
focusedMode={true}
on:change={(e) => {
data.diagnostic[screen.setIndex].gradients[screen.gradientIndex].value = e.detail ?? null;
data = data;
refreshKey++; // Force component refresh
}}
on:notes={(e) => {
data.diagnostic[screen.setIndex].gradients[screen.gradientIndex].notes = e.detail;
data = data;
refreshKey++; // Force component refresh
}}
/>
{/key}
<div class="term-desc right-desc">
<div class="term-name">{screen.gradient.term_right}</div>
<div class="description-text">{screen.gradient.term_right_description}</div>
</div>
</div>
</div>
{:else if currentScreenData.type === 'analysis'}
{@const screen = currentScreenData}
<div class="focused-screen gradient-screen">
<div class="screen-category">ANALYSIS</div>
<div class="gradient-focused">
<div class="term-desc left-desc">
<div class="term-name">{screen.gradient.term_left}</div>
<div class="description-text">{screen.gradient.term_left_description}</div>
</div>
{#key refreshKey}
<AnalysisDisplay
gradient={screen.gradient}
focusedMode={true}
on:change={(e) => {
if (!screen.gradient.automated) {
data.analysis[screen.index].value = e.detail ?? null;
data = data;
refreshKey++; // Force component refresh
}
}}
on:notes={(e) => {
data.analysis[screen.index].notes = e.detail;
data = data;
refreshKey++; // Force component refresh
}}
/>
{/key}
<div class="term-desc right-desc">
<div class="term-name">{screen.gradient.term_right}</div>
<div class="description-text">{screen.gradient.term_right_description}</div>
</div>
</div>
</div>
{:else if currentScreenData.type === 'export'}
<div class="focused-screen">
<ExportControls {data} focusedMode={true} on:reset={handleReset} />
</div>
{/if}
</div>
<!-- Navigation and Progress -->
<div class="focused-navigation" class:no-border={currentScreenData.type === 'export'}>
<div class="nav-buttons">
<button
class="nav-btn prev-btn"
on:click={goToPrevScreen}
disabled={currentScreen === 0}
aria-label="Previous screen"
>
← Previous
</button>
<button
class="nav-btn next-btn"
on:click={goToNextScreen}
disabled={currentScreen === totalScreens - 1}
aria-label="Next screen"
>
Next →
</button>
</div>
<div class="progress-indicator">
<div class="progress-bar">{progressBar}</div>
<div class="progress-numbers">{currentScreen + 1} / {totalScreens}</div>
</div>
</div>
{/if}
</main> </main>
<style> <style>
@@ -175,6 +400,9 @@
max-width: 800px; max-width: 800px;
margin: 0 auto; margin: 0 auto;
padding: 1rem; padding: 1rem;
min-height: 100vh;
display: flex;
flex-direction: column;
} }
.header { .header {
@@ -182,6 +410,7 @@
margin-bottom: 2rem; margin-bottom: 2rem;
border-bottom: 2px solid var(--border-color); border-bottom: 2px solid var(--border-color);
padding-bottom: 1rem; padding-bottom: 1rem;
position: relative;
} }
.title { .title {
@@ -190,6 +419,27 @@
letter-spacing: 0.2rem; letter-spacing: 0.2rem;
} }
.mode-toggle {
position: absolute;
top: 0;
right: 0;
padding: 0.3rem 0.6rem;
font-size: 0.9rem;
cursor: pointer;
background: var(--bg-color);
color: var(--fg-color);
border: 1px solid var(--border-color);
min-height: auto;
opacity: 0.4;
transition: opacity 0.2s;
}
.mode-toggle:hover {
opacity: 0.8;
background-color: var(--input-bg);
border-color: var(--border-color);
}
.diagnostic-set, .analysis-section { .diagnostic-set, .analysis-section {
margin: 2rem 0; margin: 2rem 0;
padding: 1rem 0; padding: 1rem 0;
@@ -211,6 +461,135 @@
opacity: 0.8; opacity: 0.8;
} }
/* Focused Mode Styles */
.focused-container {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 400px;
padding: 2rem 0;
}
.focused-screen {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.gradient-screen {
max-width: 700px;
margin: 0 auto;
width: 100%;
}
.screen-category {
text-align: center;
font-size: 1rem;
font-weight: bold;
letter-spacing: 0.2rem;
margin-bottom: 2rem;
opacity: 0.6;
}
.gradient-focused {
padding: 2rem;
border: 2px solid var(--border-color);
background-color: var(--bg-color);
display: flex;
flex-direction: column;
gap: 2rem;
}
.term-desc {
max-width: 100%;
}
.term-desc.left-desc {
text-align: left;
}
.term-desc.right-desc {
text-align: right;
}
.term-name {
font-weight: bold;
font-size: 1.1rem;
margin-bottom: 0.75rem;
color: var(--fg-color);
}
.description-text {
font-size: 0.85rem;
line-height: 1.5;
opacity: 0.8;
color: var(--fg-color);
}
.focused-navigation {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1.5rem 0;
margin-top: auto;
border-top: 2px solid var(--border-color);
}
.focused-navigation.no-border {
border-top: none;
}
.nav-buttons {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.nav-btn {
padding: 0.75rem 1.5rem;
font-size: 1rem;
min-width: 120px;
}
.nav-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.progress-indicator {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.progress-bar {
font-family: 'Courier New', monospace;
font-size: 1rem;
letter-spacing: 0.1rem;
font-weight: bold;
color: var(--fg-color);
}
.progress-numbers {
font-size: 0.9rem;
font-weight: bold;
letter-spacing: 0.1rem;
opacity: 0.7;
}
@media (max-width: 768px) { @media (max-width: 768px) {
main { main {
padding: 0.5rem; padding: 0.5rem;
@@ -223,5 +602,34 @@
.set-header { .set-header {
font-size: 1rem; font-size: 1rem;
} }
.mode-toggle {
font-size: 0.85rem;
padding: 0.25rem 0.5rem;
}
.progress-bar {
font-size: 0.85rem;
letter-spacing: 0.05rem;
}
.progress-numbers {
font-size: 0.8rem;
}
.focused-container {
padding: 1rem 0;
}
.gradient-focused {
padding: 1rem;
gap: 1.5rem;
}
.nav-btn {
min-width: 80px;
padding: 0.5rem 1rem;
font-size: 0.9rem;
}
} }
</style> </style>

View File

@@ -4,6 +4,7 @@
import Tooltip from './Tooltip.svelte'; import Tooltip from './Tooltip.svelte';
export let gradient: AnalysisGradient; export let gradient: AnalysisGradient;
export let focusedMode: boolean = false;
const dispatch = createEventDispatcher<{ const dispatch = createEventDispatcher<{
change: number | null; change: number | null;
@@ -98,12 +99,14 @@
</script> </script>
<div class="analysis-gradient"> <div class="analysis-gradient">
<div class="gradient-row"> <div class="gradient-row" class:focused={focusedMode}>
<div class="term left"> {#if !focusedMode}
<Tooltip text={gradient.term_left_description}> <div class="term left">
{gradient.term_left} <Tooltip text={gradient.term_left_description}>
</Tooltip> {gradient.term_left}
</div> </Tooltip>
</div>
{/if}
<div class="bar-container"> <div class="bar-container">
<button <button
@@ -124,11 +127,13 @@
</button> </button>
</div> </div>
<div class="term right"> {#if !focusedMode}
<Tooltip text={gradient.term_right_description}> <div class="term right">
{gradient.term_right} <Tooltip text={gradient.term_right_description}>
</Tooltip> {gradient.term_right}
</div> </Tooltip>
</div>
{/if}
</div> </div>
<div class="value-display"> <div class="value-display">
@@ -200,6 +205,15 @@
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.gradient-row.focused {
display: flex;
justify-content: center;
}
.gradient-row.focused .bar-container {
width: 100%;
}
.term { .term {
text-align: center; text-align: center;
font-size: 0.9rem; font-size: 0.9rem;
@@ -257,6 +271,11 @@
font-size: 0.9rem; font-size: 0.9rem;
} }
.analysis-gradient .value-display {
margin-top: 0.5rem;
margin-bottom: 0.25rem;
}
.auto-label { .auto-label {
opacity: 0.6; opacity: 0.6;
font-size: 0.8rem; font-size: 0.8rem;

View File

@@ -3,6 +3,7 @@
import type { BicorderState } from '../types'; import type { BicorderState } from '../types';
export let data: BicorderState; export let data: BicorderState;
export let focusedMode: boolean = false;
const dispatch = createEventDispatcher<{ const dispatch = createEventDispatcher<{
reset: void; reset: void;
@@ -121,7 +122,7 @@
} }
</script> </script>
<section class="export-controls"> <section class="export-controls" class:focused={focusedMode}>
<div class="button-group"> <div class="button-group">
<button on:click={exportToJSON}> <button on:click={exportToJSON}>
💾 Export JSON 💾 Export JSON
@@ -170,6 +171,10 @@
border-top: 2px solid var(--border-color); border-top: 2px solid var(--border-color);
} }
.export-controls.focused {
border-top: none;
}
.button-group { .button-group {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));

View File

@@ -4,6 +4,7 @@
import Tooltip from './Tooltip.svelte'; import Tooltip from './Tooltip.svelte';
export let gradient: Gradient; export let gradient: Gradient;
export let focusedMode: boolean = false;
const dispatch = createEventDispatcher<{ const dispatch = createEventDispatcher<{
change: number; change: number;
@@ -94,12 +95,14 @@
</script> </script>
<div class="gradient-container"> <div class="gradient-container">
<div class="gradient-row"> <div class="gradient-row" class:focused={focusedMode}>
<div class="term left"> {#if !focusedMode}
<Tooltip text={gradient.term_left_description}> <div class="term left">
{gradient.term_left} <Tooltip text={gradient.term_left_description}>
</Tooltip> {gradient.term_left}
</div> </Tooltip>
</div>
{/if}
<div class="bar-container"> <div class="bar-container">
<button <button
@@ -117,11 +120,13 @@
</button> </button>
</div> </div>
<div class="term right"> {#if !focusedMode}
<Tooltip text={gradient.term_right_description}> <div class="term right">
{gradient.term_right} <Tooltip text={gradient.term_right_description}>
</Tooltip> {gradient.term_right}
</div> </Tooltip>
</div>
{/if}
</div> </div>
<div class="value-display"> <div class="value-display">
@@ -179,6 +184,15 @@
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.gradient-row.focused {
display: flex;
justify-content: center;
}
.gradient-row.focused .bar-container {
width: 100%;
}
.term { .term {
text-align: center; text-align: center;
font-size: 0.9rem; font-size: 0.9rem;
@@ -231,6 +245,11 @@
font-size: 0.9rem; font-size: 0.9rem;
} }
.gradient-container .value-display {
margin-top: 0.5rem;
margin-bottom: 0.25rem;
}
.controls { .controls {
margin-top: 0.5rem; margin-top: 0.5rem;
display: flex; display: flex;

View File

@@ -1,15 +1,22 @@
<script lang="ts"> <script lang="ts">
import { tick } from 'svelte';
export let text: string; export let text: string;
let showTooltip = false; let showTooltip = false;
let touchTimer: number; let touchTimer: number;
let isLongPress = false; let isLongPress = false;
let tooltipElement: HTMLSpanElement;
let wrapperElement: HTMLSpanElement;
let tooltipPosition = { align: 'center', vertical: 'top' };
function handleTouchStart() { async function handleTouchStart() {
isLongPress = false; isLongPress = false;
touchTimer = window.setTimeout(() => { touchTimer = window.setTimeout(async () => {
showTooltip = true; showTooltip = true;
isLongPress = true; isLongPress = true;
await tick();
await updateTooltipPosition();
}, 500); // Show after 500ms press }, 500); // Show after 500ms press
} }
@@ -23,12 +30,14 @@
} }
} }
function handleClick() { async function handleClick() {
// Toggle tooltip on click/tap // Toggle tooltip on click/tap
if (!isLongPress) { if (!isLongPress) {
showTooltip = !showTooltip; showTooltip = !showTooltip;
// Auto-hide after 3 seconds // Auto-hide after 3 seconds
if (showTooltip) { if (showTooltip) {
await tick();
await updateTooltipPosition();
setTimeout(() => { setTimeout(() => {
showTooltip = false; showTooltip = false;
}, 3000); }, 3000);
@@ -37,16 +46,59 @@
isLongPress = false; isLongPress = false;
} }
function handleMouseEnter() { async function handleMouseEnter() {
showTooltip = true; showTooltip = true;
await tick();
await updateTooltipPosition();
} }
function handleMouseLeave() { function handleMouseLeave() {
showTooltip = false; showTooltip = false;
} }
async function updateTooltipPosition() {
// Wait for next frame to ensure rendering is complete
await new Promise(resolve => requestAnimationFrame(resolve));
if (!tooltipElement || !wrapperElement) return;
const wrapperRect = wrapperElement.getBoundingClientRect();
const tooltipRect = tooltipElement.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const padding = 10;
// Calculate tooltip width and position
const tooltipWidth = tooltipRect.width;
const wrapperCenterX = wrapperRect.left + wrapperRect.width / 2;
// Determine horizontal alignment
let align = 'center';
// Check if center alignment would overflow
const centerLeft = wrapperCenterX - tooltipWidth / 2;
const centerRight = wrapperCenterX + tooltipWidth / 2;
if (centerLeft < padding) {
// Would overflow left, align to left edge of wrapper
align = 'left';
} else if (centerRight > viewportWidth - padding) {
// Would overflow right, align to right edge of wrapper
align = 'right';
}
// Check vertical overflow
let vertical = 'top';
if (tooltipRect.top < padding) {
vertical = 'bottom';
}
tooltipPosition = { align, vertical };
}
</script> </script>
<span <span
bind:this={wrapperElement}
class="tooltip-wrapper" class="tooltip-wrapper"
on:mouseenter={handleMouseEnter} on:mouseenter={handleMouseEnter}
on:mouseleave={handleMouseLeave} on:mouseleave={handleMouseLeave}
@@ -61,7 +113,16 @@
> >
<slot /> <slot />
{#if showTooltip} {#if showTooltip}
<span class="tooltip-text" role="status" aria-live="polite"> <span
bind:this={tooltipElement}
class="tooltip-text"
class:align-left={tooltipPosition.align === 'left'}
class:align-right={tooltipPosition.align === 'right'}
class:align-center={tooltipPosition.align === 'center'}
class:position-bottom={tooltipPosition.vertical === 'bottom'}
role="status"
aria-live="polite"
>
{text} {text}
</span> </span>
{/if} {/if}
@@ -79,19 +140,43 @@
.tooltip-text { .tooltip-text {
position: absolute; position: absolute;
bottom: 125%; bottom: 125%;
left: 50%;
transform: translateX(-50%);
background-color: var(--bg-color); background-color: var(--bg-color);
color: var(--fg-color); color: var(--fg-color);
border: 2px solid var(--border-color); border: 2px solid var(--border-color);
padding: 0.5rem; padding: 0.5rem;
z-index: 1000; z-index: 1000;
width: max-content; width: max-content;
max-width: 300px; max-width: min(300px, calc(100vw - 20px));
font-size: 0.85rem; font-size: 0.85rem;
line-height: 1.4; line-height: 1.4;
text-align: center; text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
word-wrap: break-word;
white-space: normal;
}
/* Horizontal alignment variants */
.tooltip-text.align-center {
left: 50%;
transform: translateX(-50%);
}
.tooltip-text.align-left {
left: 0;
right: auto;
transform: none;
}
.tooltip-text.align-right {
right: 0;
left: auto;
transform: none;
}
/* Vertical position variant (when tooltip would overflow top) */
.tooltip-text.position-bottom {
bottom: auto;
top: 125%;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@@ -100,7 +185,8 @@
} }
} }
.tooltip-text::after { /* Arrow positioning for center-aligned tooltip */
.tooltip-text.align-center::after {
content: ''; content: '';
position: absolute; position: absolute;
top: 100%; top: 100%;
@@ -111,9 +197,48 @@
border-color: var(--border-color) transparent transparent transparent; border-color: var(--border-color) transparent transparent transparent;
} }
.tooltip-text.align-center.position-bottom::after {
top: auto;
bottom: 100%;
border-color: transparent transparent var(--border-color) transparent;
}
/* Arrow positioning for left-aligned tooltip */
.tooltip-text.align-left::after {
content: '';
position: absolute;
top: 100%;
left: 1rem;
border-width: 5px;
border-style: solid;
border-color: var(--border-color) transparent transparent transparent;
}
.tooltip-text.align-left.position-bottom::after {
top: auto;
bottom: 100%;
border-color: transparent transparent var(--border-color) transparent;
}
/* Arrow positioning for right-aligned tooltip */
.tooltip-text.align-right::after {
content: '';
position: absolute;
top: 100%;
right: 1rem;
border-width: 5px;
border-style: solid;
border-color: var(--border-color) transparent transparent transparent;
}
.tooltip-text.align-right.position-bottom::after {
top: auto;
bottom: 100%;
border-color: transparent transparent var(--border-color) transparent;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.tooltip-text { .tooltip-text {
max-width: 250px;
font-size: 0.8rem; font-size: 0.8rem;
} }
} }

View File

@@ -15,15 +15,27 @@ export default defineConfig({
svelte(), svelte(),
VitePWA({ VitePWA({
registerType: 'autoUpdate', registerType: 'autoUpdate',
includeAssets: ['favicon.ico'], includeAssets: ['favicon.ico', 'favicon.svg', 'icon-192.png', 'icon-512.png'],
manifest: { manifest: {
name: 'Protocol Bicorder', name: 'Protocol Bicorder',
short_name: 'Bicorder', short_name: 'Bicorder',
description: 'A diagnostic tool for the study of protocols', description: 'A diagnostic tool for the study of protocols',
theme_color: '#ffffff', theme_color: '#1a1a2e',
background_color: '#ffffff', background_color: '#1a1a2e',
display: 'standalone', display: 'standalone',
icons: [ icons: [
{
src: '/icon-192.png',
sizes: '192x192',
type: 'image/png',
purpose: 'any'
},
{
src: '/icon-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any'
},
{ {
src: '/icon.svg', src: '/icon.svg',
sizes: 'any', sizes: 'any',