Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,10 @@ Every path below exists; keep this list in step with `src/` when adding modules.
- `GuidancePanel.js` - The armed mode's guidance column, and the 40px rail it
collapses to. Collapse is remembered per user; with no stored choice the
column decides by the panel's own width
- `CursorReadout.js` - The instrument face: time, frequency and doppler speed.
Reads the pointer, or the selected feature when there is one
- `CursorReadout.js` - The instrument face: frequency as the one large, lit
reading, with time immediately beneath it and doppler speed at the foot,
both at half the size in the label grey. Reads the pointer, or the selected
feature when there is one
- `AnnotationTables.js` - The three table columns, their headers, count chips
and the "Clear all annotations" footer
- `icons.js` - The button glyphs, drawn as inline SVG in `currentColor` so
Expand Down
12 changes: 9 additions & 3 deletions docs/Control-Panel.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,15 @@ The stored preference distinguishes all three (`true` / `false` / absent). Were
### 3. The cursor readouts

The panel's anchor, and the only part styled as an instrument: recessed ground,
large monospaced tabular numerals, and a faint bloom on the two readings the
pointer produces. Doppler speed is fenced off at the foot between two rules at
half the size, because it is a derived quantity rather than a coordinate.
monospaced tabular numerals, and a faint bloom on the one reading that is lit.

That reading is the **frequency**. In use it is by far the most important
number in the panel, and the first pass set time at the same size directly
above it, so the two competed for the eye and neither was the first read. Now
frequency is the one large reading, and **time** sits immediately beneath it at
half the size, captioned, in the same grey as the panel's labels rather than
in ink. Doppler speed is fenced off at the foot between two rules at that same
second rank, because it is a derived quantity rather than a coordinate.

The column has two targets, named by its kicker:

Expand Down
35 changes: 19 additions & 16 deletions src/components/CursorReadout.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
/**
* The instrument face: the numbers the panel is anchored on.
*
* Three readouts in a recessed column — time, frequency, and the doppler speed
* pinned to the foot — set large, monospaced and tabular so a digit never
* shifts sideways as it changes. Everything else in the control row is a
* control; this is the measurement, and it is styled to look like one.
* Three readouts in a recessed column, in two ranks. Frequency is the one
* large, lit reading: in use it is by far the most important number in the
* panel, and giving time the same size made the two compete for the eye.
* Time sits immediately beneath it at half the size in the label grey, and
* the doppler speed is pinned to the foot at the same rank. All three are
* monospaced and tabular so a digit never shifts sideways as it changes.
* Everything else in the control row is a control; this is the measurement,
* and it is styled to look like one.
*
* The column has two targets, named by its kicker. With nothing selected it
* follows the pointer over the gram ("CURSOR"). With a feature selected it
Expand Down Expand Up @@ -42,34 +46,33 @@ export function createCursorReadout() {
kicker.textContent = 'Cursor'
column.appendChild(kicker)

const timeLED = createLEDDisplay('Time (mm:ss)', formatTime(0), 'MM:SS')
column.appendChild(timeLED)

// A hairline fading out to the right, rather than a full rule: it separates
// the two readings without drawing a box around either.
const separator = document.createElement('div')
separator.className = 'gram-frame-readout-separator'
column.appendChild(separator)

const freqLED = createLEDDisplay('Frequency (Hz)', '0.0', 'HZ')
freqLED.classList.add('gram-frame-led-accent')
column.appendChild(freqLED)

// Time is the second-rank reading: directly under the frequency, at half its
// size and in the label grey, with its own caption since it no longer has
// the size to name itself. It is not fenced like doppler — it belongs with
// the frequency, as the other half of the pointer's coordinate.
const timeLED = createLEDDisplay('Time (mm:ss)', formatTime(0), 'MM:SS', 'Time')
timeLED.classList.add('gram-frame-led-secondary')
column.appendChild(timeLED)

const spacer = document.createElement('div')
spacer.className = 'gram-frame-readout-spacer'
column.appendChild(spacer)

// Doppler is a derived quantity rather than a coordinate, so it is fenced off
// at the foot between two rules and set at half the size — present, but not
// competing with the two readings the pointer actually produces.
// at the foot between two rules, at the same second rank as time — present,
// but not competing with the reading the column exists for.
//
// Its accessible name stays the full "Doppler Speed (kts)" while the caption
// beside the number is the one word there is room for. The gap in that name
// is a non-breaking space, written as a \u00a0 escape so it stays visible in
// the source; Playwright normalises it to a plain space, so the
// `:text-is("Doppler Speed (kts)")` locators in tests/helpers still match.
const speedLED = createLEDDisplay('Doppler\u00a0Speed (kts)', '0.0', 'KTS', 'Doppler')
speedLED.classList.add('gram-frame-led-inline')
speedLED.classList.add('gram-frame-led-secondary', 'gram-frame-led-inline')
column.appendChild(speedLED)

return { column, timeLED, freqLED, speedLED, kicker }
Expand Down
39 changes: 24 additions & 15 deletions src/gramframe.css
Original file line number Diff line number Diff line change
Expand Up @@ -1168,12 +1168,6 @@ table.gram-config.gram-frame-config-error tr:first-child td::after {
white-space: nowrap;
}

.gram-frame-readout-separator {
height: 1px;
margin: 9px 0;
background: linear-gradient(to right, var(--gf-divider), transparent);
}

.gram-frame-readout-spacer {
flex: 1;
}
Expand All @@ -1192,7 +1186,8 @@ table.gram-config.gram-frame-config-error tr:first-child td::after {
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
/* The faint bloom is what makes it read as a lit instrument rather than as
large text. It is the only glow in the panel, and only on these two. */
large text. It is the only glow in the panel, and only the frequency
keeps it: the two secondary readings switch it off below. */
text-shadow: 0 0 18px rgba(247, 247, 250, 0.2);
}

Expand All @@ -1214,22 +1209,36 @@ table.gram-config.gram-frame-config-error tr:first-child td::after {
text-transform: uppercase;
}

/* The doppler speed: a derived quantity, so it is fenced off at the foot
between two rules and set at half the size of the two readings the pointer
itself produces. */
.gram-frame-led-inline {
/* The second rank: time and the doppler speed. Half the frequency's size, no
bloom, and set in the same grey as the panel's labels rather than in ink,
so the frequency is the one reading that is lit. The caption on the left
names the row, since at this size the number cannot name itself. */
.gram-frame-led-secondary {
flex: none;
padding: 9px 0;
border-top: 1px solid var(--gf-divider);
border-bottom: 1px solid var(--gf-divider);
}

.gram-frame-led-inline .gram-frame-led-value {
.gram-frame-led-secondary .gram-frame-led-value {
font-size: 19px;
color: var(--gf-muted);
text-shadow: none;
margin-left: auto;
}

/* Time sits directly under the frequency, as the other half of the pointer's
coordinate; a small gap rather than a rule, so they read as one reading and
its qualifier. */
.gram-frame-led-secondary:not(.gram-frame-led-inline) {
margin-top: 6px;
}

/* The doppler speed is a derived quantity rather than a coordinate, so it is
also fenced off at the foot between two rules. */
.gram-frame-led-inline {
padding: 9px 0;
border-top: 1px solid var(--gf-divider);
border-bottom: 1px solid var(--gf-divider);
}

/* --- Column 4: the style panel ------------------------------------------- */

.gram-frame-color-picker {
Expand Down
17 changes: 12 additions & 5 deletions tests/unit/multi-tab-merge.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ import { mergeStoredAnnotations } from '../../src/core/storage.js'
/** No deletions. */
const noTombstones = () => ({ markers: {}, harmonicSets: {}, sidebandSets: {}, doppler: null })

/**
* The clock every case runs under unless it says otherwise: midday on the day
* the fixtures are dated. `mergeStoredAnnotations` defaults `now` to the wall
* clock, and a tombstone older than the TTL is forgotten -- so a case dated
* 2026-09-05 that left `now` to the wall clock passed until 2026-09-12 and
* failed every day after. Pinned here, once, so the fixtures never age.
*/
const NOW = Date.parse('2026-09-05T12:00:00.000Z')

/**
* The ids in a list of features, sorted -- what most of these cases assert.
* @param {any[]} features - Markers or pin sets
Expand All @@ -38,13 +47,11 @@ const ids = features => features.map((/** @type {any} */ f) => f.id).sort()
* guarded at each of the fifty-odd reads below (R9-10, issue #262).
* @param {any} mine - What this tab would write
* @param {any} theirs - What is already stored
* @param {number} [now] - Current epoch milliseconds, for tombstone pruning
* @param {number} [now=NOW] - Current epoch milliseconds, for tombstone pruning
* @returns {any} The merged record
*/
function merge(mine, theirs, now = undefined) {
const merged = now === undefined
? mergeStoredAnnotations(mine, theirs)
: mergeStoredAnnotations(mine, theirs, now)
function merge(mine, theirs, now = NOW) {
const merged = mergeStoredAnnotations(mine, theirs, now)
expect(merged).not.toBeNull()
return merged
}
Expand Down
Loading