From 9f50afd748beb9fec233f26ddc0b0bc5213f9569 Mon Sep 17 00:00:00 2001 From: Eduardo Borjas Date: Thu, 11 Jun 2026 11:26:39 -0600 Subject: [PATCH] =?UTF-8?q?feat:=20v3.5.0=20=E2=80=94=20classic/bold=20red?= =?UTF-8?q?esigned=20as=205-row=20shade=20weights=20for=20exact=20footprin?= =?UTF-8?q?t=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit classic/bold were tall 9-row letterforms that could not share the 5-row footprint of the other styles: in a minimized, non-zen window with metadata they towered over the others, covered the session goal, or silently fell back to the block style (the "press B and only block shows" bug). Redesign them as 5-row shade-weight variants of block — the EXACT same 5-row × 4-col geometry, inked with ▒ (classic, light weight) / ▓ (bold, heavy) instead of full █. All six styles now share one identical footprint; classic and bold render their own ink at every window size, never falling back to block or hiding the goal. planCounter drops the tall-font / fallback-to-block path; uniformCounterScale becomes a thin wrapper over computeSessionScale. Verified across 1189 windows: footprint parity in every window, zero fallbacks, goal visibility consistent across all six styles. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 18 ++- README.md | 34 +++-- package.json | 2 +- src/lib/bigfont.ts | 121 ++++----------- src/schemas/config.ts | 4 +- src/tui/views/session.ts | 80 ++++------ src/version.ts | 2 +- test/bigfont.test.ts | 167 ++++++++++++++------- test/session-view.test.ts | 301 ++++++++++++++++++++------------------ 9 files changed, 372 insertions(+), 357 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86ac67d..8f86229 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [3.5.0] - 2026-06-11 + +### Changed + +- **`classic` and `bold` redesigned as 5-row shade-weight variants of `block` + (light `▒` / heavy `▓`), sharing the exact 5-row × 4-col footprint of every + other style.** They previously rendered as taller 9-row letterforms. + +### Fixed + +- **In a minimized, non-zen window with session metadata, `classic`/`bold` no + longer tower over the other styles, cover the session goal, or silently fall + back to the `block` style** — every style now occupies an identical footprint + and renders its own glyphs at every window size. + ## [3.4.1] - 2026-06-11 ### Fixed @@ -370,7 +385,8 @@ ANSI, zero new runtime dependencies, instant cold start. `FLOWCLOCK_CONFIG_DIR` / `FLOWCLOCK_DATA_DIR` overrides. - Color themes: `neon` (default), `amber`, `blue`, `mono`. -[Unreleased]: https://github.com/aedneth/flowclock-cli/compare/v3.4.1...HEAD +[Unreleased]: https://github.com/aedneth/flowclock-cli/compare/v3.5.0...HEAD +[3.5.0]: https://github.com/aedneth/flowclock-cli/compare/v3.4.1...v3.5.0 [3.4.1]: https://github.com/aedneth/flowclock-cli/compare/v3.4.0...v3.4.1 [3.4.0]: https://github.com/aedneth/flowclock-cli/compare/v3.3.1...v3.4.0 [3.3.1]: https://github.com/aedneth/flowclock-cli/compare/v3.3.0...v3.3.1 diff --git a/README.md b/README.md index 86f3831..b4e2837 100644 --- a/README.md +++ b/README.md @@ -132,9 +132,10 @@ Ratio 1:4.3 focus:rest `minimal` at every scale, including scale 1 (minimized windows). - **`minimal`** — clean **light** line digits in box-drawing strokes (`┌─┐ │ └─┘`, airy `○` colon). Scales crisply at every size. - - **`classic`** — tall, solid, **terminal-style numerals** that recreate a - familiar monospace clock (light weight). - - **`bold`** — the `classic` letterforms with heavier strokes. + - **`classic`** — solid terminal numerals in a **light shade weight** (`▒`), + sharing the exact 5-row × 4-col footprint of every other style. + - **`bold`** — solid terminal numerals in a **heavy shade weight** (`▓`), + sharing the exact 5-row × 4-col footprint of every other style. The goal sits **centered** above the counter and the focus/break metadata centered below it. Override the standalone HUD per session with `start --big`. @@ -147,11 +148,11 @@ Ratio 1:4.3 focus:rest ████ ████ ┗━━┛ ┗━━┛ ╚══╝ ╚══╝ minimal classic bold - ┌──┐ ┌──┐ ███ ███ ████ ████ - │ │ │ │ █ █ █ █ ██ ██ ██ ██ - │ │ │ │ █ █ █ █ ██ ██ ██ ██ - └──┘ └──┘ █ █ █ █ ██ ██ ██ ██ - ███ ███ ████ ████ + ┌──┐ ┌──┐ ▒▒▒ ▒▒▒▒ ▓▓▓ ▓▓▓▓ + │ │ │ │ ▒ ▒ ▒ ▒ ▓ ▓ ▓ ▓ + │ │ │ │ ▒▒▒▒▒ ▒▒▒▒▒ ▓▓▓▓▓ ▓▓▓▓▓ + └──┘ └──┘ ▒ ▒ ▒ ▒ ▓ ▓ ▓ ▓ + ▒▒▒▒▒ ▒▒▒▒▒ ▓▓▓▓▓ ▓▓▓▓▓ ``` - **Themes** — `neon` (default), `amber`, `blue`, `mono`. Set the default with @@ -255,17 +256,19 @@ so they stay prominent without overshadowing the metadata: at every scale (including scale 1 / minimized windows). - **`minimal`** — clean **light** line digits in box-drawing strokes (`┌─┐ │`), airy `○` colon. Scales crisply at every size; lighter feel than `simple`. -- **`classic`** — tall, solid, **terminal-style numerals** (light weight) — a - familiar monospace clock. -- **`bold`** — the `classic` letterforms with heavier strokes. +- **`classic`** — solid terminal numerals in a **light shade weight** (`▒`), + sharing the exact 5-row × 4-col footprint of every other style. +- **`bold`** — solid terminal numerals in a **heavy shade weight** (`▓`), + sharing the exact 5-row × 4-col footprint of every other style. Press **`d`** (or run `display` from the palette) to cycle `block → simple → outline → minimal → classic → bold`, and **`t`** to cycle the theme. Both choices are **saved to your config** as your new default — the same as running `config set displayStyle classic` / `config set theme neon`. The -public default stays `block`; the rest are opt-in alternates. (`classic`/`bold` -are taller 9-row fonts scaled to the same rendered footprint as the 5-row fonts, -so cycling styles no longer changes the counter's overall size.) +public default stays `block`; the rest are opt-in alternates. All six styles +share the same 5-row footprint — `classic`/`bold` are shade-weight variants of +`block` (`▒` light / `▓` heavy), so cycling styles never changes the counter's +size or covers the session goal. ### Live session controls @@ -399,7 +402,7 @@ Stored at `config.json` in your config dir (`flowclock config path`). Keys: | Key | Default | Meaning | | --- | --- | --- | | `theme` | `neon` | `neon` · `amber` · `blue` · `mono` | -| `displayStyle` | `block` | Counter look: `block` solid (default), `simple` heavy line digits, `outline` hollow silhouette in double-line chars (`╔═╗`) distinct from `minimal`, `minimal` light line digits, `classic` tall solid terminal numerals, or `bold` heavier — all same reserve-first scaling; cycle live with `d` | +| `displayStyle` | `block` | Counter look: `block` solid (default), `simple` heavy line digits, `outline` hollow silhouette in double-line chars (`╔═╗`) distinct from `minimal`, `minimal` light line digits, `classic` light-shade `▒` solid numerals, `bold` heavy-shade `▓` — all share the exact 5-row footprint; cycle live with `d` | | `showControls` | `true` | show the controls footer (`--zen` overrides) | | `dailyFocusGoalS` | `14400` | daily focus goal in seconds (drives maximization %) | | `keybindings.{pause,break,category,reset,quit}` | `p` `b` `c` `r` `q` | in-session keys | @@ -432,6 +435,7 @@ on-disk schema is **v3**; migrations are non-destructive. | **v3.3.1** ✅ | `outline` redrawn as a light seven-segment font (no more garbling at large scale); `classic`/`bold` degrade gracefully (drop goal line → block font) instead of collapsing to a text clock in a minimized window with metadata | | **v3.4.0** ✅ | restored the original silhouette `outline` style (hollow double-wall nested-rectangle); added `minimal` (light seven-segment line font); uniform counter scaling so `classic`/`bold` no longer tower over the 5-row fonts in minimized/zen windows | | **v3.4.1** ✅ | `outline` double-line box-drawing chars (`╔═╗`) — distinct from `minimal` at every scale; uniform `classic`/`bold` footprint — height capped to 5-row reference, degrades to `block` in short windows | +| **v3.5.0** ✅ | `classic`/`bold` redesigned as 5-row shade weights (`▒`/`▓`) for exact footprint parity with all styles — no more towering, goal covering, or silent fallback to `block` | | **next** | `flowclock sync` — push `sessions.json` to a self-hosted/cloud endpoint; recurring goals; dashboard filters | | **later** | Per-goal analytics deep-dives, calendar heatmap, Homebrew tap | diff --git a/package.json b/package.json index d3cff7f..4bd5f79 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flowclock-cli", - "version": "3.4.1", + "version": "3.5.0", "description": "Flowtime count-up terminal timer (HUD, not Pomodoro) with silent session logging — agent-native, like gh and vercel.", "type": "module", "license": "AGPL-3.0-or-later", diff --git a/src/lib/bigfont.ts b/src/lib/bigfont.ts index d844f85..b0f9ea3 100644 --- a/src/lib/bigfont.ts +++ b/src/lib/bigfont.ts @@ -321,17 +321,13 @@ export function computeSessionScale( } /** - * Pick a scale that gives every style a CONSISTENT rendered footprint, so the - * counter does not jump in size when cycling display styles with `d`. - * - * The problem: the tall (9-row) `classic`/`bold` fonts, scaled independently to - * fill the area, rendered ~1.8× taller than the 5-row fonts at the same window - * size — towering over them and even crowding out the goal line. Here the 5-row - * `block` font is the reference: we scale every style toward the SAME target - * rendered height (block's), so all styles occupy roughly the same vertical - * footprint. 5-row styles are unaffected (they already match block exactly); - * the tall fonts pick the LARGEST integer scale whose height does not exceed - * the reference, so they never overshoot the line fonts or crowd the goal line. + * Pick the counter scale for `style`. Every display style now shares the exact + * 5-row × 4-col `block` footprint (the tall 9-row `classic`/`bold` letterforms + * were retired in favour of 5-row shade weights — see STYLE_METRICS), so a + * single scale gives ALL styles an identical rendered size. Cycling styles with + * `d` therefore never makes the counter jump in size, and no style-specific + * "taming" is needed — this is now a thin, style-aware wrapper over + * `computeSessionScale` kept for a stable call site and clear intent. */ export function uniformCounterScale( areaCols: number, @@ -339,32 +335,20 @@ export function uniformCounterScale( time: string, style: DisplayStyle, ): number { - // Reference: how tall the 5-row block font renders in this area. - const refScale = computeSessionScale(areaCols, areaRows, time, { style: "block" }); - const refHeight = refScale * styleBaseRows("block"); - - // Largest scale that physically fits this style in the area. - const natural = computeSessionScale(areaCols, areaRows, time, { style }); - - // 5-row styles already match the reference; only the tall fonts need taming. - if (styleBaseRows(style) === styleBaseRows("block")) return natural; - - // Scale the tall font to the LARGEST integer height that does NOT exceed the - // reference (floor, not round) — so classic/bold never tower over the line - // fonts or crowd the goal line — then clamp to what actually fits the area. - const target = Math.max(1, Math.floor(refHeight / styleBaseRows(style))); - return Math.min(natural, target); + return computeSessionScale(areaCols, areaRows, time, { style }); } // --------------------------------------------------------------------------- -// Tall solid "classic" / "bold" fonts — terminal-style numerals +// Solid shade-weight "classic" / "bold" fonts — terminal numerals // --------------------------------------------------------------------------- /** - * Per-style glyph geometry. `block`/`simple`/`outline`/`minimal` share the - * original 5-row, 4-wide-digit footprint; `classic`/`bold` are taller (9 rows) - * solid letterforms. Threading these metrics through the scaling maths keeps the - * reserve-first layout exact for every style. + * Per-style glyph geometry. EVERY style shares the original 5-row, 4-wide-digit, + * 1-wide-colon footprint, so the reserve-first scaling maths and small-panel + * fallback thresholds are byte-for-byte identical across all six styles. The + * styles differ only in how each filled cell is INKED (see `renderCounter`): + * `block` full `█`, `classic`/`bold` the lighter `▒` / heavier `▓` shade + * weights, and `simple`/`outline`/`minimal` box-drawing line-art. */ interface StyleMetrics { rows: number; @@ -377,8 +361,8 @@ const STYLE_METRICS: Record = { simple: { rows: BIG_ROWS, digitW: 4, colonW: 1 }, outline: { rows: BIG_ROWS, digitW: 4, colonW: 1 }, minimal: { rows: BIG_ROWS, digitW: 4, colonW: 1 }, - classic: { rows: 9, digitW: 5, colonW: 1 }, - bold: { rows: 9, digitW: 6, colonW: 2 }, + classic: { rows: BIG_ROWS, digitW: 4, colonW: 1 }, + bold: { rows: BIG_ROWS, digitW: 4, colonW: 1 }, }; /** Number of text rows the style occupies at scale 1. */ @@ -398,71 +382,30 @@ export function styleWidth(style: DisplayStyle, time: string, scale = 1): number return w * scale; } -// 5-wide × 9-tall LIGHT letterforms (the `classic` style). -const CLASSIC_GLYPHS: Record = { - "0": [" ███ ", "█ █", "█ █", "█ █", "█ █", "█ █", "█ █", "█ █", " ███ "], - "1": [" █ ", " ██ ", " █ ", " █ ", " █ ", " █ ", " █ ", " █ ", " ███ "], - "2": [" ███ ", "█ █", " █", " █", " █ ", " █ ", " █ ", "█ ", "█████"], - "3": [" ███ ", "█ █", " █", " ██ ", " █", " █", " █", "█ █", " ███ "], - "4": [" █ ", " ██ ", " █ █ ", "█ █ ", "█████", " █ ", " █ ", " █ ", " █ "], - "5": ["█████", "█ ", "█ ", "████ ", " █", " █", " █", "█ █", " ███ "], - "6": [" ███ ", "█ █", "█ ", "█ ", "████ ", "█ █", "█ █", "█ █", " ███ "], - "7": ["█████", " █", " █ ", " █ ", " █ ", " █ ", " █ ", " █ ", " █ "], - "8": [" ███ ", "█ █", "█ █", "█ █", " ███ ", "█ █", "█ █", "█ █", " ███ "], - "9": [" ███ ", "█ █", "█ █", "█ █", " ████", " █", " █", "█ █", " ███ "], - ":": [" ", " ", "█", " ", " ", " ", "█", " ", " "], -}; - -// 6-wide × 9-tall HEAVY letterforms (the `bold` style). -const BOLD_GLYPHS: Record = { - "0": [" ████ ", "██ ██", "██ ██", "██ ██", "██ ██", "██ ██", "██ ██", "██ ██", " ████ "], - "1": [" ██ ", " ███ ", " ██ ", " ██ ", " ██ ", " ██ ", " ██ ", " ██ ", " ████ "], - "2": [" ████ ", "██ ██", " ██", " ██ ", " ██ ", " ██ ", "██ ", "██ ", "██████"], - "3": [" ████ ", "██ ██", " ██", " ███ ", " ██", " ██", " ██", "██ ██", " ████ "], - "4": [" ██ ", " ███ ", " ████ ", "██ ██ ", "██████", " ██ ", " ██ ", " ██ ", " ██ "], - "5": ["██████", "██ ", "██ ", "█████ ", " ██", " ██", " ██", "██ ██", " ████ "], - "6": [" ████ ", "██ ██", "██ ", "██ ", "█████ ", "██ ██", "██ ██", "██ ██", " ████ "], - "7": ["██████", " ██", " ██ ", " ██ ", " ██ ", " ██ ", " ██ ", " ██ ", " ██ "], - "8": [" ████ ", "██ ██", "██ ██", "██ ██", " ████ ", "██ ██", "██ ██", "██ ██", " ████ "], - "9": [" ████ ", "██ ██", "██ ██", "██ ██", " █████", " ██", " ██", "██ ██", " ████ "], - ":": [" ", " ", "██", "██", " ", "██", "██", " ", " "], -}; +/** The shade glyph each solid weight inks its filled cells with. */ +const CLASSIC_FILL = "▒"; // light/medium shade — the `classic` weight +const BOLD_FILL = "▓"; // heavy shade — the `bold` weight /** - * Render a 9-row solid-letterform font (classic/bold) by integer cell-repeat — - * crisp at every scale, no half-block artefacts. Width parity with - * `styleWidth` is guaranteed by the glyph tables matching STYLE_METRICS. + * Render the solid `block` geometry with a different fill glyph. `classic` and + * `bold` are weight variants of the default `block` font: they share its EXACT + * 5-row × 4-col cell grid (so every dimension, scale step and layout reservation + * is byte-for-byte identical to block), and differ only by inking each filled + * cell with a lighter `▒` / heavier `▓` shade instead of the full `█`. Reusing + * `renderBigLines` guarantees the footprint can never drift from block's. */ -function renderTallSolid( - time: string, - scale: number, - glyphs: Record, - rows: number, - digitW: number, -): string[] { - const H = rows * scale; - const out = Array.from({ length: H }, () => ""); - const chars = [...time]; - const blank = Array.from({ length: rows }, () => " ".repeat(digitW)); - chars.forEach((ch, idx) => { - const g = glyphs[ch] ?? blank; - const sep = idx < chars.length - 1 ? " ".repeat(scale) : ""; - for (let r = 0; r < rows; r++) { - const line = [...(g[r] ?? "")].map((c) => c.repeat(scale)).join(""); - for (let s = 0; s < scale; s++) out[r * scale + s] += line + sep; - } - }); - return out; +function renderShaded(time: string, scale: number, fill: string): string[] { + return renderBigLines(time, scale).map((line) => line.replaceAll("█", fill)); } -/** Render the `classic` style — tall LIGHT solid terminal numerals. */ +/** Render the `classic` style — solid LIGHT shade (`▒`) terminal numerals. */ export function renderClassicLines(time: string, scale = 1): string[] { - return renderTallSolid(time, scale, CLASSIC_GLYPHS, STYLE_METRICS.classic.rows, STYLE_METRICS.classic.digitW); + return renderShaded(time, scale, CLASSIC_FILL); } -/** Render the `bold` style — tall HEAVY solid terminal numerals. */ +/** Render the `bold` style — solid HEAVY shade (`▓`) terminal numerals. */ export function renderBoldLines(time: string, scale = 1): string[] { - return renderTallSolid(time, scale, BOLD_GLYPHS, STYLE_METRICS.bold.rows, STYLE_METRICS.bold.digitW); + return renderShaded(time, scale, BOLD_FILL); } /** Render `time` in the requested display style at the given scale. */ diff --git a/src/schemas/config.ts b/src/schemas/config.ts index b4b13f0..9cb6500 100644 --- a/src/schemas/config.ts +++ b/src/schemas/config.ts @@ -12,8 +12,8 @@ export type ThemeName = z.infer; * - "simple" clean heavy box-drawing seven-segment digits * - "outline" hollow DOUBLE-LINE silhouette glyphs (╔═╗ ║ ╚╝ — distinct at every scale) * - "minimal" clean light box-drawing seven-segment digits (airy line font) - * - "classic" tall solid terminal-style numerals (light weight) - * - "bold" tall solid terminal-style numerals (heavy weight) + * - "classic" solid terminal numerals in a LIGHT shade weight (▒) + * - "bold" solid terminal numerals in a HEAVY shade weight (▓) */ export const DisplayStyleSchema = z.enum([ "simple", diff --git a/src/tui/views/session.ts b/src/tui/views/session.ts index f53de83..67378cd 100644 --- a/src/tui/views/session.ts +++ b/src/tui/views/session.ts @@ -20,7 +20,6 @@ import { renderCounter, styleWidth, styleBaseRows, - computeSessionScale, uniformCounterScale, } from "../../lib/bigfont.js"; import type { DisplayStyle } from "../../schemas/config.js"; @@ -73,22 +72,19 @@ interface CounterPlan { /** * Decide how to fit the counter into the available rows — the graceful - * degradation ladder that keeps a real glyph clock on screen far longer: + * degradation ladder that keeps a real glyph clock on screen as long as possible: * - * 1. requested style, but a tall (9-row) font is used ONLY while it stays - * within the 5-row block reference's footprint (so it never towers over - * the line fonts or crowds the goal); keeping the goal line; + * 1. requested style, keeping the goal line; * 2. requested style, with the goal line DROPPED to reclaim two rows; - * 3. tall fonts (classic/bold) that would tower — or don't fit — fall back to - * the 5-row `block` font, keeping then dropping the goal, so the counter's - * footprint stays uniform across every style; - * 4. only when even a 5-row font cannot fit do we collapse to the single + * 3. only when even a glyph font cannot fit do we collapse to the single * centered text line. * - * The footprint gate is the key to a consistent size across styles: a 9-row - * font cannot occupy the same 5 rows as the line fonts, so in a window too short - * to render it without dwarfing them, classic/bold degrade to block. In roomier - * windows they keep their tall identity at a height that matches block. + * EVERY style now shares the exact 5-row × 4-col `block` footprint (`classic`/ + * `bold` are shade-weight variants of block, not the old taller letterforms), so + * the requested style renders at every window size the others do. There is no + * tall-font case and no fall-back to a different style: cycling styles never + * changes the counter's footprint, and classic/bold are never silently swapped + * for block in a tight window. */ function planCounter( innerW: number, @@ -104,38 +100,11 @@ function planCounter( const fits = (st: DisplayStyle, gr: number): boolean => innerW >= styleWidth(st, time, 1) && availRows(gr) >= styleBaseRows(st); - const isTall = styleBaseRows(style) > styleBaseRows("block"); - - // A tall (9-row) font keeps its footprint only if the 5-row block reference - // would render at least as tall in the same area — i.e. there is room for the - // tall font to sit at/below block's height rather than towering over it. - const tallKeepsFootprint = (gr: number): boolean => { - if (!fits(style, gr)) return false; - const blockH = - computeSessionScale(innerW, Math.max(1, availRows(gr)), time, { style: "block" }) * - styleBaseRows("block"); - return blockH >= styleBaseRows(style); - }; - - if (!isTall) { - // 1-2: a 5-row style — keep the goal if it fits, else drop it. - if (fits(style, goalRows)) return { style, showGoal: hasGoal }; - if (hasGoal && fits(style, 0)) return { style, showGoal: false }; - } else { - // 1: a tall style — kept ONLY while it fits WITH the goal and stays within - // the block footprint. We deliberately do NOT drop the goal to squeeze a - // tall font in: the line fonts keep the goal at this size, so dropping it - // for classic/bold would make the counter both taller AND goal-less than - // every other style. Falling back to block (below) keeps the goal. - if (tallKeepsFootprint(goalRows)) return { style, showGoal: hasGoal }; - // 2-3: it would tower (or not fit with the goal) → fall back to the 5-row - // block font — keeping then dropping the goal exactly as the line fonts - // would — so the footprint and goal stay uniform across every style. - if (fits("block", goalRows)) return { style: "block", showGoal: hasGoal }; - if (hasGoal && fits("block", 0)) return { style: "block", showGoal: false }; - } + // 1-2: keep the requested style; keep the goal if it fits, else drop it. + if (fits(style, goalRows)) return { style, showGoal: hasGoal }; + if (hasGoal && fits(style, 0)) return { style, showGoal: false }; - // 5: last resort — single centered text line. Keep the goal only if there is + // 3: last resort — single centered text line. Keep the goal only if there is // still a row for the clock after the goal + metadata. return { style: "text", showGoal: hasGoal && availRows(goalRows) >= 1 }; } @@ -212,10 +181,10 @@ export function renderSession( // -- Counter scaling: RESERVE-FIRST, with graceful degradation ------------ // Plan how the counter fits the available rows: keep the requested style if it - // fits, else drop the goal line, else fall back from a tall (9-row) font to - // the 5-row block font, and only collapse to a single text line as a last - // resort. This prevents the classic/bold fonts from collapsing in a minimized - // window that is also showing session metadata. + // fits, else drop the goal line, and only collapse to a single text line as a + // last resort. Because every style shares block's 5-row footprint, the chosen + // style — including classic/bold — renders at its true glyphs in a minimized + // window with metadata, and is never silently swapped for a different style. const hasGoal = !!goalText && !zen; const plan = planCounter(innerW, innerH, time, displayStyle, hasGoal, bottomLines.length); @@ -244,17 +213,18 @@ export function renderSession( counterLines = [line]; } else { const counterAreaRows = Math.max(styleBaseRows(plan.style), innerH - reserved); - // Uniform scaling: every style targets the same rendered footprint as the - // 5-row block font, so cycling styles never makes the counter jump in size - // (the tall classic/bold fonts no longer tower over the line fonts). + // Uniform scaling: every style shares the 5-row × 4-col block footprint, so a + // single scale gives them all an identical rendered size — cycling styles + // never makes the counter jump in size. const scale = uniformCounterScale(innerW, counterAreaRows, time, plan.style); // Every style shares the reserve-first scaling maths via style-aware metrics // (styleWidth / styleBaseRows); they differ only in glyph rendering: - // "block" solid block glyphs (default; also the tall-font fallback) + // "block" solid full-block █ glyphs (default) // "simple" clean heavy box-drawing seven-segment line digits - // "outline" light box-drawing seven-segment line digits (scales cleanly) - // "classic" tall LIGHT solid terminal numerals - // "bold" tall HEAVY solid terminal numerals + // "outline" double-line box-drawing silhouette digits + // "minimal" light box-drawing seven-segment line digits + // "classic" solid LIGHT shade ▒ terminal numerals + // "bold" solid HEAVY shade ▓ terminal numerals const rawLines = renderCounter(plan.style, time, scale); counterLines = rawLines.map((line) => { const padded = padTo(line, innerW, "center"); diff --git a/src/version.ts b/src/version.ts index ff8761a..273f1ea 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ /** Kept in sync with package.json "version" (asserted by a test). */ -export const VERSION = "3.4.1"; +export const VERSION = "3.5.0"; diff --git a/test/bigfont.test.ts b/test/bigfont.test.ts index 9bdd4da..b50c388 100644 --- a/test/bigfont.test.ts +++ b/test/bigfont.test.ts @@ -286,17 +286,49 @@ describe("renderMinimalLines (light seven-segment style)", () => { }); }); -describe("renderClassicLines / renderBoldLines (tall solid terminal fonts)", () => { - it("classic is 9 rows tall at scale 1 and scales the row count by scale", () => { - expect(renderClassicLines("12:34:56", 1)).toHaveLength(9); - expect(renderClassicLines("0", 3)).toHaveLength(27); - expect(styleBaseRows("classic")).toBe(9); +// --------------------------------------------------------------------------- +// renderClassicLines / renderBoldLines — 5-row shade-weight variants of block +// +// These are NO LONGER tall 9-row terminal fonts. They share the exact 5-row × +// 4-col block footprint and differ only by inking filled cells with ▒ (classic) +// or ▓ (bold) rather than █. +// --------------------------------------------------------------------------- + +describe("renderClassicLines / renderBoldLines (5-row shade-weight variants of block)", () => { + it("classic is 5 rows tall at scale 1 — same as block (styleBaseRows == BIG_ROWS)", () => { + expect(renderClassicLines("12:34:56", 1)).toHaveLength(BIG_ROWS); + expect(renderClassicLines("0", 3)).toHaveLength(BIG_ROWS * 3); + expect(styleBaseRows("classic")).toBe(BIG_ROWS); + expect(styleBaseRows("classic")).toBe(styleBaseRows("block")); + }); + + it("bold is 5 rows tall at scale 1 — same as block (styleBaseRows == BIG_ROWS)", () => { + expect(renderBoldLines("12:34:56", 1)).toHaveLength(BIG_ROWS); + expect(renderBoldLines("0", 2)).toHaveLength(BIG_ROWS * 2); + expect(styleBaseRows("bold")).toBe(BIG_ROWS); + expect(styleBaseRows("bold")).toBe(styleBaseRows("block")); + }); + + it("styleWidth for classic and bold equals styleWidth for block (same footprint)", () => { + const t = "12:34:56"; + for (const scale of [1, 2, 3]) { + expect(styleWidth("classic", t, scale)).toBe(styleWidth("block", t, scale)); + expect(styleWidth("bold", t, scale)).toBe(styleWidth("block", t, scale)); + } + }); + + it("classic row count equals block row count at scales 1, 2, 3", () => { + const t = "12:34:56"; + for (const scale of [1, 2, 3]) { + expect(renderClassicLines(t, scale).length).toBe(renderBigLines(t, scale).length); + } }); - it("bold is 9 rows tall at scale 1 and scales the row count by scale", () => { - expect(renderBoldLines("12:34:56", 1)).toHaveLength(9); - expect(renderBoldLines("0", 2)).toHaveLength(18); - expect(styleBaseRows("bold")).toBe(9); + it("bold row count equals block row count at scales 1, 2, 3", () => { + const t = "12:34:56"; + for (const scale of [1, 2, 3]) { + expect(renderBoldLines(t, scale).length).toBe(renderBigLines(t, scale).length); + } }); it("rendered width exactly matches styleWidth (parity for reserve-first maths)", () => { @@ -311,34 +343,67 @@ describe("renderClassicLines / renderBoldLines (tall solid terminal fonts)", () } }); - it("uses solid block characters, no box-drawing line art", () => { + it("classic inks with ▒ (not █), bold inks with ▓ (not █)", () => { const c = renderClassicLines("8", 2).join(""); - expect(c).toContain("█"); + expect(c).toContain("▒"); + expect(c).not.toContain("█"); expect(/[─│┌┐└┘┃━]/.test(c)).toBe(false); + const b = renderBoldLines("8", 2).join(""); - expect(b).toContain("█"); + expect(b).toContain("▓"); + expect(b).not.toContain("█"); }); - it("bold is heavier than classic (more ink for the same time)", () => { - const ink = (rows: string[]) => rows.join("").replace(/ /g, "").length; - expect(ink(renderBoldLines("12:34:56", 1))).toBeGreaterThan(ink(renderClassicLines("12:34:56", 1))); + it("classic line[i] == block line[i] with █→▒ at scale 1 (exact geometry reuse)", () => { + const t = "12:34:56"; + const blockLines = renderBigLines(t, 1); + const classicLines = renderClassicLines(t, 1); + blockLines.forEach((blockLine, i) => { + expect(classicLines[i]).toBe(blockLine.replaceAll("█", "▒")); + }); }); - it("classic and bold are visually distinct from block, simple, outline", () => { - const samples = ["12:34:56"] as const; - for (const t of samples) { - const classic = renderClassicLines(t, 2).join("\n"); - const bold = renderBoldLines(t, 2).join("\n"); - expect(classic).not.toBe(bold); - // distinct dimensions from the 5-row families guarantees distinctness - expect(renderClassicLines(t, 1).length).not.toBe(renderBigLines(t, 1).length); - } + it("classic line[i] == block line[i] with █→▒ at scale 2 (geometry reuse at scale 2)", () => { + const t = "12:34:56"; + const blockLines = renderBigLines(t, 2); + const classicLines = renderClassicLines(t, 2); + blockLines.forEach((blockLine, i) => { + expect(classicLines[i]).toBe(blockLine.replaceAll("█", "▒")); + }); + }); + + it("bold line[i] == block line[i] with █→▓ at scale 1 (exact geometry reuse)", () => { + const t = "12:34:56"; + const blockLines = renderBigLines(t, 1); + const boldLines = renderBoldLines(t, 1); + blockLines.forEach((blockLine, i) => { + expect(boldLines[i]).toBe(blockLine.replaceAll("█", "▓")); + }); + }); + + it("bold line[i] == block line[i] with █→▓ at scale 2 (geometry reuse at scale 2)", () => { + const t = "12:34:56"; + const blockLines = renderBigLines(t, 2); + const boldLines = renderBoldLines(t, 2); + blockLines.forEach((blockLine, i) => { + expect(boldLines[i]).toBe(blockLine.replaceAll("█", "▓")); + }); + }); + + it("classic and bold are visually distinct from block and from each other", () => { + const t = "12:34:56"; + const classic = renderClassicLines(t, 1).join("\n"); + const bold = renderBoldLines(t, 1).join("\n"); + const block = renderBigLines(t, 1).join("\n"); + expect(classic).not.toBe(bold); + expect(classic).not.toBe(block); + expect(bold).not.toBe(block); }); it("never throws on unexpected characters", () => { expect(() => renderClassicLines("ab", 2)).not.toThrow(); expect(() => renderBoldLines("??", 3)).not.toThrow(); - expect(renderClassicLines("?")).toHaveLength(9); + expect(renderClassicLines("?")).toHaveLength(BIG_ROWS); }); }); @@ -359,12 +424,15 @@ describe("computeSessionScale — style-aware", () => { expect(styleWidth("block", "00:00:00", 1)).toBe(bigWidth("00:00:00", 1)); }); - it("tall classic font yields a smaller scale than block in the same area", () => { - // 9-row classic needs more vertical room per scale step than 5-row block. + it("classic and bold yield the SAME scale as block — all share the 5-row footprint", () => { + // All styles now share the identical 5-row × 4-col footprint, so + // computeSessionScale must return the same value for block, classic, and bold. const area = { c: 120, r: 24 } as const; const blockScale = computeSessionScale(area.c, area.r, "00:00:00", { style: "block" }); const classicScale = computeSessionScale(area.c, area.r, "00:00:00", { style: "classic" }); - expect(classicScale).toBeLessThanOrEqual(blockScale); + const boldScale = computeSessionScale(area.c, area.r, "00:00:00", { style: "bold" }); + expect(classicScale).toBe(blockScale); + expect(boldScale).toBe(blockScale); }); it("still returns >= 1 for every style on a tiny area", () => { @@ -375,30 +443,26 @@ describe("computeSessionScale — style-aware", () => { }); describe("uniformCounterScale — consistent footprint across styles", () => { - it("5-row styles return the same scale as computeSessionScale (block reference)", () => { + it("all styles return the same scale as computeSessionScale (all share 5-row footprint)", () => { const time = "00:00:00"; for (const area of [{ c: 98, r: 23 }, { c: 120, r: 31 }] as const) { - for (const style of ["block", "simple", "outline", "minimal"] as const) { + for (const style of ["block", "simple", "outline", "minimal", "classic", "bold"] as const) { const expected = computeSessionScale(area.c, area.r, time, { style }); expect(uniformCounterScale(area.c, area.r, time, style)).toBe(expected); } } }); - it("tall fonts do NOT tower: classic returns smaller scale than independent computeSessionScale in a half-window", () => { - // At cols=98, rows=23, time="00:29:35" the block font renders at scale 2 (height 10). - // The OLD independent classic scale would be 2 (height 18 — towering). - // uniformCounterScale caps it to scale 1 (height 9) which fits the block reference. + it("classic and bold return the SAME scale as block (no tall-font taming needed)", () => { + // uniformCounterScale is now a thin wrapper over computeSessionScale. + // Because all styles share the 5-row footprint, classic and bold get the + // same scale as block — no floor() taming is applied. const cols = 98, rows = 23, time = "00:29:35"; - const blockRef = computeSessionScale(cols, rows, time, { style: "block" }); - expect(blockRef).toBe(2); // confirm the reference is scale 2 - - const oldClassic = computeSessionScale(cols, rows, time, { style: "classic" }); - const uniformClassic = uniformCounterScale(cols, rows, time, "classic"); - // uniform should be less than the OLD independent scale (which would tower) - expect(uniformClassic).toBeLessThan(oldClassic); - // and the rendered height must not wildly exceed the block reference height - expect(uniformClassic * 9).toBeLessThanOrEqual(blockRef * 5 + 9); + const blockScale = uniformCounterScale(cols, rows, time, "block"); + const classicScale = uniformCounterScale(cols, rows, time, "classic"); + const boldScale = uniformCounterScale(cols, rows, time, "bold"); + expect(classicScale).toBe(blockScale); + expect(boldScale).toBe(blockScale); }); it("returns >= 1 for every style on a tiny area", () => { @@ -407,17 +471,14 @@ describe("uniformCounterScale — consistent footprint across styles", () => { } }); - it("v3.4.1 — floor semantics: classic height never exceeds block reference height at cols=120, rows=30", () => { - // At 120×30 the block reference reaches scale 3 (height 15). - // Math.floor(15 / 9) = 1, so classic is capped at scale 1 (height 9). - // Math.round would give round(15/9)=round(1.67)=2 (height 18 — would tower). + it("v3.5 — classic and bold now equal block at cols=120, rows=30 (unified footprint)", () => { + // The old v3.4.1 floor() taming that kept classic at scale 1 when block was + // at scale 3 is gone. Now both are at scale 3. const cols = 120, rows = 30, time = "00:00:00"; - const blockRef = computeSessionScale(cols, rows, time, { style: "block" }); - expect(blockRef).toBe(3); // confirm block reaches scale 3 here - - const refHeight = blockRef * 5; // 5-row block reference height = 15 + const blockScale = uniformCounterScale(cols, rows, time, "block"); const classicScale = uniformCounterScale(cols, rows, time, "classic"); - // floor semantics: classic height must NOT exceed block reference height - expect(classicScale * 9).toBeLessThanOrEqual(refHeight); + const boldScale = uniformCounterScale(cols, rows, time, "bold"); + expect(classicScale).toBe(blockScale); + expect(boldScale).toBe(blockScale); }); }); diff --git a/test/session-view.test.ts b/test/session-view.test.ts index 637c958..bc3a64d 100644 --- a/test/session-view.test.ts +++ b/test/session-view.test.ts @@ -44,6 +44,31 @@ function rect(width: number, height: number): Rect { return { top: 0, left: 0, width, height }; } +/** Strip ANSI so we can inspect raw text + leading whitespace. */ +function strip(s: string): string { + // eslint-disable-next-line no-control-regex + return s.replace(/\x1b\[[0-9;]*m/g, ""); +} + +/** + * Count "pure glyph rows": lines where every non-space character (ignoring the + * panel border │ that wraps each row) belongs to the given ink set. This correctly + * ignores the progress bar (▕██████████▏) and panel borders (single-line + * box-drawing), making it reliable for block/classic/bold whose inks (█ ▒ ▓) + * never appear in panel borders. + * + * The panel border renders as │...content...│ on each inner row, so we strip + * the leading and trailing │ before checking the ink constraint. + */ +function glyphRows(lines: string[], inks: string[]): number { + return lines.filter((l) => { + // Strip panel border │ from both ends, then trim spaces. + const stripped = l.replace(/^[│┌┐└┘─\s]+/, "").replace(/[│┌┐└┘─\s]+$/, ""); + if (!stripped) return false; + return [...stripped].every((ch) => ch === " " || inks.includes(ch)); + }).length; +} + // --------------------------------------------------------------------------- // ACTIVE — 80×24 (standard terminal) // --------------------------------------------------------------------------- @@ -376,12 +401,6 @@ describe("renderSession — outline display style", () => { // Centered layout + zen (v3.3.0) // --------------------------------------------------------------------------- -/** Strip ANSI so we can inspect raw text + leading whitespace. */ -function strip(s: string): string { - // eslint-disable-next-line no-control-regex - return s.replace(/\x1b\[[0-9;]*m/g, ""); -} - describe("renderSession — centered goal + metadata", () => { // Rows include the 1-col panel border, so we measure the TEXT position: // centered text has substantial blank space on BOTH sides. @@ -446,13 +465,12 @@ describe("renderSession — classic & bold display styles", () => { // --------------------------------------------------------------------------- // Minimized window + metadata + NON-zen — the collapse bug (v3.3.1) // -// Tall fonts (classic/bold) used to collapse straight to a one-line text clock -// when a minimized window was also showing session metadata. They must instead -// degrade gracefully to a real glyph clock (their own font once it fits, else -// the 5-row block font) while keeping the focus/break metadata visible. +// Classic/bold now share block's 5-row footprint, so they render their own +// ink (▒ / ▓) in a minimized window with metadata — never falling back to +// block's █ glyphs and never collapsing to a bare text line. // --------------------------------------------------------------------------- -describe("renderSession — tall fonts do not collapse in a minimized window", () => { +describe("renderSession — classic/bold render own ink in a minimized window (no fallback)", () => { // Time with all-distinct digits so a lone "00:12:34" text line is detectable. const mini = (style: SessionViewState["displayStyle"], h: number) => renderSession( @@ -462,27 +480,41 @@ describe("renderSession — tall fonts do not collapse in a minimized window", ( false, ).map(strip); - for (const style of ["classic", "bold"] as const) { - it(`${style}: H14 with metadata renders a real glyph clock, not a text line`, () => { - const rows = mini(style, 14); - const joined = rows.join("\n"); - // Metadata still present (the whole point of NON-zen). - expect(joined).toContain("focus"); - expect(joined).toContain("break"); - // A real block-glyph counter is drawn (solid blocks in the counter area). - expect(joined).toContain("█"); - // The counter is NOT the collapsed single text line: the literal clock - // string must NOT appear as a contiguous run. - expect(joined).not.toContain("00:12:34"); - }); + it("classic: H14 renders ▒ glyphs (its own ink), not █ or bare text", () => { + const rows = mini("classic", 14); + const joined = rows.join("\n"); + // Metadata still present (the whole point of NON-zen). + expect(joined).toContain("focus"); + expect(joined).toContain("break"); + // Classic renders its own shade ink ▒, NOT block's █. + expect(joined).toContain("▒"); + // The counter is NOT the collapsed single text line. + expect(joined).not.toContain("00:12:34"); + }); - it(`${style}: H12 (tighter) still shows a glyph clock + metadata`, () => { - const joined = mini(style, 12).join("\n"); - expect(joined).toContain("focus"); - expect(joined).toContain("█"); - expect(joined).not.toContain("00:12:34"); - }); - } + it("classic: H12 (tighter) still shows ▒ glyphs + metadata (no fallback to block)", () => { + const joined = mini("classic", 12).join("\n"); + expect(joined).toContain("focus"); + expect(joined).toContain("▒"); + expect(joined).not.toContain("00:12:34"); + }); + + it("bold: H14 renders ▓ glyphs (its own ink), not █ or bare text", () => { + const rows = mini("bold", 14); + const joined = rows.join("\n"); + expect(joined).toContain("focus"); + expect(joined).toContain("break"); + // Bold renders its own shade ink ▓, NOT block's █. + expect(joined).toContain("▓"); + expect(joined).not.toContain("00:12:34"); + }); + + it("bold: H12 (tighter) still shows ▓ glyphs + metadata (no fallback to block)", () => { + const joined = mini("bold", 12).join("\n"); + expect(joined).toContain("focus"); + expect(joined).toContain("▓"); + expect(joined).not.toContain("00:12:34"); + }); it("outline: H16 renders box-drawing line-art (not a collapsed single text line)", () => { const rows = mini("outline", 16); @@ -525,67 +557,11 @@ describe("renderSession — minimal display style", () => { }); // --------------------------------------------------------------------------- -// uniformCounterScale: classic/bold no longer tower over block/simple/minimal +// v3.5 — classic/bold share block's footprint (Bug 1 + Bug 2 regression guards +// updated to reflect the new shade-weight design) // --------------------------------------------------------------------------- -describe("renderSession — classic/bold no longer tower over block in half/minimized window", () => { - // Before the fix, classic at scale 2 produced 18 rows of glyphs vs block's 10 - // rows — causing the goal line to be dropped. The fix caps classic/bold so - // their rendered height is comparable to block. We verify by checking that the - // goal text "Deep work" is still present for classic at rect(100,30). - it("classic keeps goal text visible at rect(100,30) — not dropped by oversized counter", () => { - const classicRows = renderSession( - activeState({ displayStyle: "classic", goal: "Deep work" }), - rect(100, 30), - "neon", - false, - ).map(strip); - expect(classicRows.join("\n")).toContain("Deep work"); - }); - - it("bold keeps goal text visible at rect(100,30)", () => { - const boldRows = renderSession( - activeState({ displayStyle: "bold", goal: "Deep work" }), - rect(100, 30), - "neon", - false, - ).map(strip); - expect(boldRows.join("\n")).toContain("Deep work"); - }); - - it("classic glyph-row count is within a small delta of block at rect(100,30)", () => { - // block/classic/bold all draw solid █ counters; detecting █ isolates the - // counter rows (the panel border is │, and the only other █ — the focus - // progress bar — lives on a row containing "focus", which we exclude). - const isGlyphRow = (r: string) => - /█/.test(r) && - !/focus|break|Flowclock|Session|Deep work/.test(r); - - const blockCount = renderSession( - activeState({ displayStyle: "block" }), - rect(100, 30), - "neon", - false, - ).map(strip).filter(isGlyphRow).length; - - const classicCount = renderSession( - activeState({ displayStyle: "classic" }), - rect(100, 30), - "neon", - false, - ).map(strip).filter(isGlyphRow).length; - - // With the fix, classic should be within 3 rows of block (both ~9-10 rows). - // Before the fix, classic was ~18 rows vs block ~10 — a delta of 8. - expect(Math.abs(classicCount - blockCount)).toBeLessThanOrEqual(3); - }); -}); - -// --------------------------------------------------------------------------- -// v3.4.1 — uniform footprint + consistent goal (Bug 1 + Bug 2 regression guards) -// --------------------------------------------------------------------------- - -describe("v3.4.1 — uniform footprint + consistent goal", () => { +describe("v3.5 — classic/bold share block's 5-row footprint (uniform counter)", () => { // State used for all sub-tests: active session with goal, focus target, and // break budget, no zen mode. const state34 = (style: SessionViewState["displayStyle"]): SessionViewState => @@ -598,56 +574,37 @@ describe("v3.4.1 — uniform footprint + consistent goal", () => { zen: false, }); - /** - * Count rows that contain counter-glyph characters (solid block or double-line - * box-drawing) but are NOT metadata or goal lines. - * We use █ (present in block/classic/bold) as the counter signal for solid-font - * styles. This function is intentionally scoped to solid (█) styles so it - * remains unambiguous even in panels with box-drawing borders. - */ - const solidCounterRows = (rows: string[]): number => - rows - .map(strip) - .filter( - (r) => - /█/.test(r) && - !/focus|break|ratio|Session|Deep work/.test(r), - ).length; - - // ── Test A: classic/bold never taller than the line fonts in a TIGHT window ── + // ── Test A: classic/bold glyph rows EXACTLY EQUAL block's — not just "close" ── - it("Test A — classic: counter rows never exceed block/simple/outline/minimal in a tight 92×17 window", () => { + it("Test A — classic: ▒ glyph rows EXACTLY equal block's █ glyph rows in a tight 92×17 window", () => { const w = 92, h = 17; const r = rect(w, h); - const lineStyles = ["block", "simple", "outline", "minimal"] as const; - const lineCounts = lineStyles.map((s) => - solidCounterRows(renderSession(state34(s), r, "neon", false)), - ); - const maxLineCount = Math.max(...lineCounts); + // Pure glyph rows: every non-space char must be the style's ink. + const blockRows = renderSession(state34("block"), r, "neon", false).map(strip); + const classicRows = renderSession(state34("classic"), r, "neon", false).map(strip); - const classicCount = solidCounterRows( - renderSession(state34("classic"), r, "neon", false), - ); - // In a tight window classic falls back to block; its height must not exceed - // the tallest line-font counter in the same window. - expect(classicCount).toBeLessThanOrEqual(maxLineCount); + const blockCount = glyphRows(blockRows, ["█"]); + const classicCount = glyphRows(classicRows, ["▒"]); + + // Exact footprint parity — delta must be 0. + expect(classicCount).toBe(blockCount); + // Classic must render at least 1 row of its own ▒ ink (no fallback to block). + expect(classicCount).toBeGreaterThanOrEqual(1); }); - it("Test A — bold: counter rows never exceed block/simple/outline/minimal in a tight 92×17 window", () => { + it("Test A — bold: ▓ glyph rows EXACTLY equal block's █ glyph rows in a tight 92×17 window", () => { const w = 92, h = 17; const r = rect(w, h); - const lineStyles = ["block", "simple", "outline", "minimal"] as const; - const lineCounts = lineStyles.map((s) => - solidCounterRows(renderSession(state34(s), r, "neon", false)), - ); - const maxLineCount = Math.max(...lineCounts); + const blockRows = renderSession(state34("block"), r, "neon", false).map(strip); + const boldRows = renderSession(state34("bold"), r, "neon", false).map(strip); - const boldCount = solidCounterRows( - renderSession(state34("bold"), r, "neon", false), - ); - expect(boldCount).toBeLessThanOrEqual(maxLineCount); + const blockCount = glyphRows(blockRows, ["█"]); + const boldCount = glyphRows(boldRows, ["▓"]); + + expect(boldCount).toBe(blockCount); + expect(boldCount).toBeGreaterThanOrEqual(1); }); // ── Test B: goal shown consistently across ALL six styles ── @@ -681,22 +638,86 @@ describe("v3.4.1 — uniform footprint + consistent goal", () => { expect(/[╔║╚╗╝═]/.test(minimalJoined)).toBe(false); }); - // ── Test D: roomy window preserves tall identity for classic ── + // ── Test D: roomy window — classic renders ▒ (own ink) at same footprint as block ── + // REPLACED: classic is now a 5-row shade font, never "tall" (> 5 glyph rows). + // This test verifies classic renders its own ▒ ink (not █) at the same glyph-row + // footprint as block in a roomy 92×28 window, with goal + % visible. - it("Test D — classic keeps tall identity (> 5 glyph rows) in a roomy 92×28 window, goal + % are present", () => { + it("Test D — classic renders ▒ (own ink, not █) at the same glyph-row footprint as block in a roomy 92×28 window, goal + % present", () => { const w = 92, h = 28; const r = rect(w, h); - const rows = renderSession(state34("classic"), r, "neon", false); - const stripped = rows.map(strip); - const joined = stripped.join("\n"); + const classicRendered = renderSession(state34("classic"), r, "neon", false); + const blockRendered = renderSession(state34("block"), r, "neon", false); - // At 92×28 classic should remain as a tall font with more than 5 solid rows. - const glyphRowCount = solidCounterRows(rows); - expect(glyphRowCount).toBeGreaterThan(5); + const classicStripped = classicRendered.map(strip); + const blockStripped = blockRendered.map(strip); + const classicJoined = classicStripped.join("\n"); - // Goal and focus-% metadata must both survive alongside the tall counter. - expect(joined).toContain("Deep work"); - expect(joined).toContain("%"); + // Glyph row parity: classic's ▒ rows == block's █ rows. + const classicGlyphCount = glyphRows(classicStripped, ["▒"]); + const blockGlyphCount = glyphRows(blockStripped, ["█"]); + expect(classicGlyphCount).toBe(blockGlyphCount); + + // Classic inks with ▒ in its glyph rows — must have at least 1. + expect(classicGlyphCount).toBeGreaterThanOrEqual(1); + // Joined content of classic's output must contain ▒. + expect(classicJoined).toContain("▒"); + + // Goal and focus-% metadata must both survive alongside the counter. + expect(classicJoined).toContain("Deep work"); + expect(classicJoined).toContain("%"); + }); + + // ── NEW guard: tight minimized window — classic/bold use their own ink, never fall back to block ── + + it("NEW — classic renders ▒ and NOT █ in glyph rows in a tight 84×17 window, goal present", () => { + const r = rect(84, 17); + const st = activeState({ + displayStyle: "classic", + goal: "Guard test", + focusTargetS: 1200, + breakBudgetS: 300, + zen: false, + }); + const rows = renderSession(st, r, "neon", false).map(strip); + const joined = rows.join("\n"); + + // Classic must use its own ▒ ink. + expect(joined).toContain("▒"); + // Goal must survive. + expect(joined).toContain("Guard test"); + + // No pure glyph row should contain █ — classic never falls back to block ink. + const blockGlyphRows = rows.filter((l) => { + const t = l.trim(); + return t && [...t].every((ch) => ch === " " || ch === "█"); + }); + expect(blockGlyphRows).toHaveLength(0); + }); + + it("NEW — bold renders ▓ and NOT █ in glyph rows in a tight 84×17 window, goal present", () => { + const r = rect(84, 17); + const st = activeState({ + displayStyle: "bold", + goal: "Guard test", + focusTargetS: 1200, + breakBudgetS: 300, + zen: false, + }); + const rows = renderSession(st, r, "neon", false).map(strip); + const joined = rows.join("\n"); + + // Bold must use its own ▓ ink. + expect(joined).toContain("▓"); + // Goal must survive. + expect(joined).toContain("Guard test"); + + // No pure glyph row should contain █ — bold never falls back to block ink. + const blockGlyphRows = rows.filter((l) => { + const t = l.trim(); + return t && [...t].every((ch) => ch === " " || ch === "█"); + }); + expect(blockGlyphRows).toHaveLength(0); }); });