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>
This commit is contained in:
Nathan Schneider
2026-06-30 16:02:16 -06:00
parent 12ac4eb943
commit f07708f296
3 changed files with 342 additions and 238 deletions
+13
View File
@@ -569,6 +569,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}
@@ -820,6 +824,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;
+183 -135
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} <span class="bracket" aria-hidden="true">[</span>
aria-valuemax={gradient.automated ? undefined : 9} {#each scaleValues as n}
aria-valuenow={gradient.automated ? undefined : gradient.value} {@const kind = cellKind(n, gradient.value)}
title={gradient.automated ? 'Auto-calculated' : 'Click or tap on the scale to set value'} <span class="cell {kind}">{cellGlyph(kind)}</span>
disabled={gradient.automated} {/each}
> <span class="bracket" aria-hidden="true">]</span>
{barDisplay} </div>
</button> {: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>
{/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>
+146 -103
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) {
event.preventDefault();
const target = event.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const touch = event.touches[0];
const x = touch.clientX - rect.left;
const width = rect.width;
// Account for button padding to get actual text area
const styles = window.getComputedStyle(target);
const paddingLeft = parseFloat(styles.paddingLeft);
const paddingRight = parseFloat(styles.paddingRight);
const contentWidth = width - paddingLeft - paddingRight;
const xInContent = x - paddingLeft;
// Clamp to content area and get ratio
const ratio = Math.max(0, Math.min(1, xInContent / contentWidth));
// Map to 1-9 with proper centering on each character position
const value = Math.min(9, Math.max(1, Math.floor(ratio * 9) + 1));
dispatch('change', value);
}
function renderBar(value: number | null): string {
// 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); // Handle the key here and keep it from bubbling to the app-level
// screen navigation (which also listens for arrow keys).
event.preventDefault();
event.stopPropagation();
dispatch('change', next);
}
</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>
</button> {#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>
{/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>