Implementation Plan — Size, Scale & Resolution (L01–L05)
Implementation Plan — Size, Scale & Resolution (L01–L05)
Created: 2026-04-04
Design doc: docs/SIZE_SCALE_RESOLUTION.md
Status: Ready to implement
Estimated sessions: 3 (Phase 1 + 2 together, Phase 3, Phase 4)
Pre-Implementation Checklist
Before starting any session:
cd /path/to/JustDoIt
git pull --rebase origin main
/home/node/.local/bin/uv run pytest tests/ -q # must be green
Read:
docs/SIZE_SCALE_RESOLUTION.md— the full design- This file — the concrete task list
justdoit/fonts/__init__.py— FONTS dict structurejustdoit/output/svg.py— to_svg() / save_svg()scripts/generate_gallery.py— gallery structure
Key facts established during analysis
- Block font: glyphs are variable width per character (A=6w, J=6w at block; A=11w at figlet-block)
measure()MUST iterate actual glyph widths from FONTS dict — cannot assume a fixed width- FIGlet glyphs also have variable widths (slant A=8w, J=9w)
svg.pyto_svg()already acceptsfont_size: int = 14— it’s just never passed by callerscast.pyalready has_detect_dims()that auto-sizes from frame content — do NOT break thisFONTSdict maps{font_name: {char: [row_str, ...]}}— glyph width =max(len(row) for row in glyph)- All builtin+FIGlet fonts have fixed height per font (all glyphs same height)
- TTF fonts: variable width, needs Pillow — defer to future work, document limitation
- Tests use
_MODULE_NAME,__updated__,__version__,__author__header pattern — follow it
Phase 1+2 — Core Primitives + SVG Scaling
One session. Target: ~150 lines of new code + ~60 lines of tests.
Task 1.1 — Create justdoit/layout.py
New file. All functions are pure Python stdlib — no Pillow, no render deps.
measure(text, font, gap, iso_depth, bloom_radius, warp_amplitude) → (cols, rows)
def measure(
text: str,
font: str = "block",
gap: int = 1,
iso_depth: int = 0,
bloom_radius: int = 0,
warp_amplitude: float = 0.0,
) -> tuple[int, int]:
Implementation notes:
- Import
FONTSfromjustdoit.fonts - Look up
font_data = FONTS.get(font)— raiseValueErrorif unknown (same as rasterizer) - For each char in
text.upper():glyph = font_data.get(char, font_data.get(" ")) glyph_w = max(len(row) for row in glyph)- Accumulate:
cols += glyph_w; addgapbetween chars (not after last) rows = len(next(iter(font_data.values())))— font height (all glyphs same height)- Apply spatial footprints after base measurement:
iso_depth > 0: cols += iso_depth; rows += iso_depthbloom_radius > 0: cols += bloom_radius * 2; rows += bloom_radius * 2 (# TODO: update when C12 is built)warp_amplitude > 0: cols += int(warp_amplitude) + 1
- Return
(cols, rows)
Edge cases:
- Empty string
""→(0, font_height)— don’t crash - Single char → no gap added
- Space char
" "→ use space glyph width - TTF fonts not in
FONTSdict → will raise ValueError (acceptable; document in docstring)
RenderTarget dataclass
@dataclass
class RenderTarget:
display_w: int
display_h: int
dpi: float = 96.0
scaling: float = 1.0
char_w_ratio: float = 0.6
Methods (all pure arithmetic — no imports beyond math and dataclasses):
| Method | Returns | Notes |
|---|---|---|
effective_dpi (property) |
float | dpi * scaling |
cell_size_px(font_pt) |
(cell_w, cell_h) floats |
cell_h = pt * (eff_dpi/72), cell_w = cell_h * ratio |
max_columns(font_pt) |
int | int(display_w / cell_w) |
max_rows(font_pt) |
int | int(display_h / cell_h) |
max_font_pt(cols_needed, rows_needed) |
int | binary-search or linear scan 1→499, return last pt where both fit |
svg_font_size_px(font_pt) |
int | int(pt * (eff_dpi / 72.0)) |
fit_font_pt(text, font, gap, iso_depth, bloom_radius) |
int | calls measure() then max_font_pt() |
from_string(spec, **kwargs) (classmethod) |
RenderTarget | parses "3840x2160" or "3840x2160@2.0x" |
max_font_pt implementation: linear scan is fine (500 iterations of trivial arithmetic
is ~microseconds). Binary search is not necessary.
def max_font_pt(self, cols_needed: int, rows_needed: int) -> int:
best = 1
for pt in range(1, 500):
if self.max_columns(pt) >= cols_needed and self.max_rows(pt) >= rows_needed:
best = pt
else:
# Once we fail, keep going — at very high pt we might fail on rows
# but a slightly smaller pt might still work on a tall display.
# So scan the full range rather than breaking early.
pass
return best
Wait — actually break logic is wrong for tall displays. A 3840×2160 monitor has MORE vertical room than a 5120×1440. At some pt, cols fail before rows. Scan full range and track the last pt where BOTH conditions are satisfied.
from_string format: r"(\d+)x(\d+)(?:@([\d.]+)x)?" — see design doc.
Named display presets
DISPLAYS: dict[str, RenderTarget] = {
"fhd": RenderTarget(1920, 1080),
"qhd": RenderTarget(2560, 1440),
"4k": RenderTarget(3840, 2160),
"5k": RenderTarget(5120, 2880),
"ultrawide": RenderTarget(5120, 1440),
"4k-hidpi": RenderTarget(3840, 2160, scaling=2.0),
"fhd-hidpi": RenderTarget(1920, 1080, scaling=2.0),
}
fit_text(text, target_cols, font, gap, iso_depth, bloom_radius, truncate, truncation_suffix) → (str, int)
def fit_text(
text: str,
target_cols: int,
font: str = "block",
gap: int = 1,
iso_depth: int = 0,
bloom_radius: int = 0,
truncate: bool = True,
truncation_suffix: str = "…",
) -> tuple[str, int]:
Implementation notes:
- First check full text:
cols, _ = measure(text, font, gap, iso_depth, bloom_radius) - If fits: return
(text, cols)immediately - If
truncate=False: raiseValueError(f"Text too wide: {cols} cols > {target_cols}") - Binary search on text length:
- Low=0, High=len(text)
- At each mid:
measure(text[:mid] + truncation_suffix, ...)— include suffix in measurement - Find longest prefix where
cols <= target_cols
- Return
(text[:best] + truncation_suffix, actual_cols) - If even 1 char + suffix doesn’t fit: return
(truncation_suffix, suffix_cols)
terminal_size() → (cols, rows)
def terminal_size() -> tuple[int, int]:
import os
try:
ts = os.get_terminal_size()
return ts.columns, ts.lines
except OSError:
return 80, 24
Simple. Already exists scattered; make it canonical.
Task 1.2 — Update justdoit/output/svg.py
Only one change: save_svg() should accept and pass through font_size:
def save_svg(text: str, path: str, font_size: int = 14, **kwargs) -> None:
svg = to_svg(text, font_size=font_size, **kwargs)
...
Currently save_svg() signature is (text, path, **kwargs) — font_size goes
via **kwargs to to_svg(). Making it explicit in the signature improves IDE
support and makes it obvious to callers.
No changes to to_svg() itself. The SVG structure, _CHAR_W_RATIO, and
textLength improvement are deferred to Phase 2+ (see open question §9.5 in design doc).
Task 1.3 — Write tests/test_layout.py
tests/test_layout.py
Test cases (all pure Python, no Pillow, no TTF):
# measure() — basic correctness
def test_measure_known_output():
"""measure() matches actual render() output dimensions."""
from justdoit.core.rasterizer import render
result = render("JUST DO IT", font="block", gap=1)
lines = result.split("\n")
actual_cols = max(len(l) for l in lines)
actual_rows = len(lines)
cols, rows = measure("JUST DO IT", font="block", gap=1)
assert cols == actual_cols
assert rows == actual_rows
def test_measure_empty():
cols, rows = measure("", font="block", gap=1)
assert cols == 0
assert rows == 7 # block font height
def test_measure_single_char():
cols, rows = measure("A", font="block", gap=1)
assert cols > 0
assert rows == 7
def test_measure_gap_zero():
cols_gap1, _ = measure("HI", font="block", gap=1)
cols_gap0, _ = measure("HI", font="block", gap=0)
assert cols_gap0 < cols_gap1
def test_measure_iso_depth():
base_cols, base_rows = measure("HI", font="block", gap=1)
iso_cols, iso_rows = measure("HI", font="block", gap=1, iso_depth=4)
assert iso_cols == base_cols + 4
assert iso_rows == base_rows + 4
def test_measure_bloom():
base_cols, base_rows = measure("HI", font="block", gap=1)
bloom_cols, bloom_rows = measure("HI", font="block", gap=1, bloom_radius=4)
assert bloom_cols == base_cols + 8 # both sides
assert bloom_rows == base_rows + 8
def test_measure_figlet():
"""measure() works for FIGlet fonts too."""
cols, rows = measure("JUST DO IT", font="slant", gap=1)
result = render("JUST DO IT", font="slant", gap=1)
lines = result.split("\n")
assert cols == max(len(l) for l in lines)
def test_measure_unknown_font():
with pytest.raises(ValueError):
measure("HI", font="nonexistent_font")
# RenderTarget
def test_render_target_cell_size():
rt = RenderTarget(3840, 2160, dpi=96.0, scaling=1.0)
cell_w, cell_h = rt.cell_size_px(12)
assert abs(cell_h - 16.0) < 0.1 # 12pt * 96/72 = 16px
assert abs(cell_w - 9.6) < 0.1 # 16 * 0.6
def test_render_target_max_columns():
rt = RenderTarget(3840, 2160)
cols = rt.max_columns(12)
assert cols == int(3840 / 9.6) # 400
def test_render_target_max_font_pt():
rt = RenderTarget(3840, 2160)
cols, rows = measure("JUST DO IT", font="block", gap=1)
pt = rt.max_font_pt(cols, rows)
# Should be around 63 based on our prior calculation
assert 55 <= pt <= 70
def test_render_target_hidpi():
rt_100 = RenderTarget(3840, 2160, scaling=1.0)
rt_200 = RenderTarget(3840, 2160, scaling=2.0)
pt_100 = rt_100.max_font_pt(76, 19)
pt_200 = rt_200.max_font_pt(76, 19)
assert pt_200 < pt_100 # higher DPI scaling = smaller max pt
def test_render_target_from_string_basic():
rt = RenderTarget.from_string("3840x2160")
assert rt.display_w == 3840
assert rt.display_h == 2160
assert rt.scaling == 1.0
def test_render_target_from_string_with_scaling():
rt = RenderTarget.from_string("3840x2160@2.0x")
assert rt.display_w == 3840
assert rt.scaling == 2.0
def test_render_target_from_string_invalid():
with pytest.raises(ValueError):
RenderTarget.from_string("notaresolution")
def test_displays_preset_4k():
rt = DISPLAYS["4k"]
assert rt.display_w == 3840
assert rt.display_h == 2160
# fit_text()
def test_fit_text_already_fits():
text, cols = fit_text("HI", target_cols=200)
assert text == "HI"
assert cols <= 200
def test_fit_text_truncates():
text, cols = fit_text("JUST DO IT", target_cols=30, font="block")
assert cols <= 30
assert len(text) < len("JUST DO IT")
def test_fit_text_no_truncate_raises():
with pytest.raises(ValueError):
fit_text("JUST DO IT", target_cols=10, font="block", truncate=False)
def test_fit_text_suffix_included_in_width():
"""The truncation suffix must be included in the column measurement."""
text, cols = fit_text("JUST DO IT", target_cols=40, font="block", truncation_suffix="...")
assert cols <= 40
# terminal_size()
def test_terminal_size_fallback():
"""terminal_size() returns a valid (cols, rows) tuple."""
cols, rows = terminal_size()
assert cols >= 1
assert rows >= 1
Task 1.4 — Validate SVG at large font sizes
After implementing, do a manual sanity check:
# Run once manually — not a unit test (visual check required)
from justdoit.core.rasterizer import render
from justdoit.output.svg import save_svg
from justdoit.layout import measure, RenderTarget, DISPLAYS
text = "JUST DO IT"
rt = DISPLAYS["4k"]
cols, rows = measure(text)
pt = rt.max_font_pt(cols, rows)
px = rt.svg_font_size_px(pt)
result = render(text, font="block", fill="flame", color="red")
save_svg(result, "/tmp/justdoit-4k-test.svg", font_size=px)
print(f"Rendered at {pt}pt ({px}px). Open /tmp/justdoit-4k-test.svg in browser.")
print(f"Check: are all columns aligned? No overlapping chars? No gaps?")
If columns drift visibly at large sizes, investigate textLength SVG fix (design doc §2.3).
Document findings in docs/decisions/ADR-007-svg-large-font-metrics.md before shipping Phase 4.
Phase 1+2 commit
git add justdoit/layout.py tests/test_layout.py justdoit/output/svg.py
git commit -m "feat: L01-L03 layout primitives — measure(), RenderTarget, fit_text()
justdoit/layout.py (new):
measure(text, font, gap, iso_depth, bloom_radius, warp_amplitude) → (cols, rows)
RenderTarget dataclass: cell_size_px, max_columns, max_rows, max_font_pt,
svg_font_size_px, fit_font_pt, from_string(), DISPLAYS presets
fit_text(text, target_cols, ...) → (fitted_text, actual_cols)
terminal_size() → (cols, rows) with 80×24 fallback
justdoit/output/svg.py:
save_svg(): make font_size explicit param (was implicit **kwargs passthrough)
tests/test_layout.py (new):
measure() correctness vs actual render() output
RenderTarget arithmetic, from_string(), presets
fit_text() truncation and suffix width inclusion
terminal_size() fallback"
git push
Phase 3 — CLI Flags
One session. Target: ~80 lines in cli.py.
Task 3.1 — Add --measure flag
parser.add_argument(
"--measure", action="store_true",
help="Print render dimensions (cols × rows) without rendering and exit.",
)
Handler (runs before render, after font resolution):
if args.measure:
from justdoit.layout import measure, DISPLAYS
iso_d = args.iso if args.iso else 0
cols, rows = measure(args.text, font=font_name, gap=args.gap, iso_depth=iso_d)
print(f"Render size: {cols} cols × {rows} rows")
print()
print("Display fits:")
for label, rt in DISPLAYS.items():
pt = rt.max_font_pt(cols, rows)
cell_w, cell_h = rt.cell_size_px(pt)
letter_h_in = (rows * cell_h) / 96.0
print(f" {label:<12} {rt.display_w}×{rt.display_h} → {pt}pt "
f"letter height {letter_h_in:.2f}\" "
f"grid {rt.max_columns(pt)}×{rt.max_rows(pt)}")
sys.exit(0)
Exit immediately after printing — do not proceed to render.
Task 3.2 — Add --target flag
parser.add_argument(
"--target", metavar="WxH[@Sx]",
help="Display target spec for sizing (e.g. 3840x2160 or 3840x2160@2.0x). "
"Used with --save-svg to auto-size font, or standalone to print fit info.",
)
Handler:
_target_rt = None
if args.target:
from justdoit.layout import RenderTarget
try:
_target_rt = RenderTarget.from_string(args.target)
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)
Auto-size SVG when --target + --save-svg:
if args.save_svg:
svg_font_size = args.svg_font_size # may be None
if svg_font_size is None and _target_rt is not None:
from justdoit.layout import measure
iso_d = args.iso if args.iso else 0
cols, rows = measure(args.text, font=font_name, gap=args.gap, iso_depth=iso_d)
pt = _target_rt.max_font_pt(cols, rows)
svg_font_size = _target_rt.svg_font_size_px(pt)
save_svg(output, args.save_svg, font_size=svg_font_size or 14)
Standalone --target output (when no --save-svg):
if _target_rt is not None and not args.save_svg:
from justdoit.layout import measure
iso_d = args.iso if args.iso else 0
cols, rows = measure(args.text, font=font_name, gap=args.gap, iso_depth=iso_d)
pt = _target_rt.max_font_pt(cols, rows)
cell_w, cell_h = _target_rt.cell_size_px(pt)
letter_h = rows * cell_h / 96.0
print(
f"Target: {_target_rt.display_w}×{_target_rt.display_h} "
f"@ {_target_rt.scaling:.1f}× scaling\n"
f"Max font size: {pt}pt (cell: {cell_w:.1f}×{cell_h:.1f}px)\n"
f"Letter height: {letter_h:.2f}\" ({letter_h*2.54:.2f} cm)\n"
f"Terminal grid: {_target_rt.max_columns(pt)}×{_target_rt.max_rows(pt)}\n"
f"SVG font-size: {_target_rt.svg_font_size_px(pt)}px",
file=sys.stderr,
)
# continue to render and print normally — --target alone doesn't suppress output
Note: standalone --target info goes to stderr so it doesn’t pollute
stdout when output is piped. The render still prints to stdout normally.
Task 3.3 — Add --svg-font-size flag
parser.add_argument(
"--svg-font-size", type=int, default=None, metavar="N",
help="Font size in pixels for SVG output (default: 14). Overrides --target.",
)
Wire into SVG save path (see Task 3.2 above — args.svg_font_size takes precedence
over --target-derived size).
Task 3.4 — Add --fit flag
parser.add_argument(
"--fit", type=int, default=None, metavar="COLS",
help="Truncate text to fit within COLS terminal columns before rendering.",
)
Handler (before render call):
text_to_render = args.text
if args.fit is not None:
from justdoit.layout import fit_text
iso_d = args.iso if args.iso else 0
text_to_render, actual_cols = fit_text(
args.text,
target_cols=args.fit,
font=font_name,
gap=args.gap,
iso_depth=iso_d,
)
if text_to_render != args.text:
print(
f"Note: text truncated to fit {args.fit} cols "
f"(rendered {actual_cols} cols)",
file=sys.stderr,
)
Then use text_to_render instead of args.text in the render() call.
Task 3.5 — Update CLI epilog/help
Add to the --help examples section:
%(prog)s "JUST DO IT" --measure
%(prog)s "JUST DO IT" --target 3840x2160
%(prog)s "JUST DO IT" --target 3840x2160 --save-svg out.svg
%(prog)s "JUST DO IT" --fit 80
Phase 3 commit
git add justdoit/cli.py
git commit -m "feat: L05 CLI size flags — --measure, --target, --fit, --svg-font-size
--measure: print (cols × rows) + fit table for all DISPLAYS presets, then exit
--target WxH[@Sx]: compute max font pt for display; auto-size SVG when combined
with --save-svg; standalone prints sizing info to stderr, render continues
--svg-font-size N: explicit SVG pixel size, overrides --target-derived size
--fit N: truncate text to fit N columns via fit_text(), warn to stderr if truncated
All four flags use layout.py primitives — no arithmetic duplicated in cli.py"
git push
Phase 4 — Gallery Profiles
One session. Target: ~120 lines in generate_gallery.py + new dirs.
Task 4.1 — Add GalleryProfile dataclass
At the top of scripts/generate_gallery.py, after imports:
from dataclasses import dataclass
from justdoit.layout import measure
@dataclass
class GalleryProfile:
"""Defines a gallery render tier — font size, README thumbnail width, output dir."""
name: str
svg_font_size: int
readme_img_width: int
output_dir: Path
text: str = "JUST DO IT"
PROFILES: dict[str, GalleryProfile] = {
"standard": GalleryProfile(
name="standard",
svg_font_size=14,
readme_img_width=480,
output_dir=Path(__file__).parent.parent / "docs" / "gallery",
),
"wide": GalleryProfile(
name="wide",
svg_font_size=28,
readme_img_width=800,
output_dir=Path(__file__).parent.parent / "docs" / "gallery-wide",
),
"4k": GalleryProfile(
name="4k",
svg_font_size=72,
readme_img_width=1600,
output_dir=Path(__file__).parent.parent / "docs" / "gallery-4k",
),
}
Task 4.2 — Add --profile flag
parser.add_argument(
"--profile", default="standard",
choices=list(PROFILES.keys()) + ["all"],
help="Gallery render profile: standard (14px), wide (28px), 4k (72px), all (default: standard)",
)
Task 4.3 — Refactor _generate_for_profile(profile: GalleryProfile)
Extract the current gallery generation loop into a profile-aware function:
def _generate_for_profile(profile: GalleryProfile, text: str) -> None:
"""Generate all gallery SVGs and README for a given profile."""
profile.output_dir.mkdir(parents=True, exist_ok=True)
entries = _curated_entries(text)
for stem, label, rendered in entries:
path = profile.output_dir / f"{stem}.svg"
save_svg(rendered, str(path), font_size=profile.svg_font_size)
_write_readme(profile, entries)
print(f"[{profile.name}] Generated {len(entries)} entries → {profile.output_dir}")
The _write_readme() function uses profile.readme_img_width instead of
the hardcoded 480.
Task 4.4 — Wire --profile all
profiles_to_run = (
list(PROFILES.values()) if args.profile == "all"
else [PROFILES[args.profile]]
)
for profile in profiles_to_run:
_generate_for_profile(profile, args.text)
Task 4.5 — Pre-render validation
Before generating any profile, validate text fits reasonably:
def _validate_text(text: str, font: str = "block", gap: int = 1) -> None:
cols, rows = measure(text, font=font, gap=gap)
if cols > 400:
print(
f"Warning: '{text}' renders to {cols} columns — SVGs will be very wide.",
file=sys.stderr,
)
if cols == 0:
raise ValueError(f"Text '{text}' produces empty output with font '{font}'")
Call before the profile loop. Not a hard error unless cols == 0.
Task 4.6 — Empirical validation at 72px
After generating the 4k profile, inspect one SVG manually:
# Open in browser and inspect character alignment
open docs/gallery-4k/S-F00-block-baseline.svg # macOS/Linux
start docs/gallery-4k/S-F00-block-baseline.svg # Windows
Check:
- Characters are monospaced and columns align
- No overlapping chars or unexpected gaps
- Background fills the full canvas
- Colors render correctly
If columns drift at 72px: implement textLength SVG fix (see design doc §2.3)
and document in docs/decisions/ADR-007-svg-large-font-metrics.md.
Phase 4 commit
git add scripts/generate_gallery.py docs/gallery-wide/ docs/gallery-4k/
git commit -m "feat: L04 gallery profiles — standard/wide/4k render tiers
scripts/generate_gallery.py:
GalleryProfile dataclass: svg_font_size, readme_img_width, output_dir
PROFILES dict: standard(14px/480px), wide(28px/800px), 4k(72px/1600px)
--profile flag: standard | wide | 4k | all
_generate_for_profile(): profile-aware SVG save + README generation
_validate_text(): warn if render is unexpectedly wide
docs/gallery-wide/ — new: 28px SVG renders, 800px README thumbnails
docs/gallery-4k/ — new: 72px SVG renders, 1600px README thumbnails
SVG column alignment validated at 72px — see ADR-007 for findings"
git push
Phase 5 — Integration & Polish (deferred)
Not required for the core feature. Pick these up in later sessions:
Task 5.1 — FIGlet font measure() validation
FIGlet fonts have variable glyph widths and some use kerning. Validate that
measure() is accurate for all bundled FIGlet fonts:
# Add to test_layout.py:
@pytest.mark.parametrize("font", ["big", "slant", "banner", "bubble", "digital", "figlet-block"])
def test_measure_all_figlet_fonts(font):
from justdoit.core.rasterizer import render
result = render("HI", font=font, gap=1)
lines = result.split("\n")
actual_cols = max(len(l) for l in lines)
cols, _ = measure("HI", font=font, gap=1)
assert cols == actual_cols, f"measure() wrong for font '{font}': {cols} != {actual_cols}"
If FIGlet kerning causes measure() to over-estimate (glyphs overlap in
render), document the known error margin. Don’t add special-case code —
document the limitation.
Task 5.2 — Animation gallery profile awareness
In scripts/generate_anim_gallery.py, add optional target_display to
SHOWCASE entries so a future --profile 4k flag can auto-size animation
terminal dimensions. Low priority — cast files are asciinema-native and
the player handles sizing at playback time.
Task 5.3 — textLength SVG fix (if needed)
If Phase 4 validation reveals column drift at 72px, implement:
# In to_svg(), replace:
elements.append(
f'<text x="{x}" y="{y}" fill="{fill}" font-size="{font_size}" ...>{safe_ch}</text>'
)
# With:
elements.append(
f'<text x="{x}" y="{y}" fill="{fill}" font-size="{font_size}" '
f'textLength="{char_w:.1f}" lengthAdjust="spacing" ...>{safe_ch}</text>'
)
Gate behind to_svg(..., fixed_width: bool = False) parameter.
Document in ADR-007.
Task 5.4 — Windows DPI auto-detection (platform-specific)
def detect_windows_dpi() -> float:
"""Attempt to detect current DPI on Windows via ctypes. Returns 96.0 on failure."""
try:
import ctypes
hdc = ctypes.windll.user32.GetDC(0)
dpi = ctypes.windll.gdi32.GetDeviceCaps(hdc, 88) # LOGPIXELSX
ctypes.windll.user32.ReleaseDC(0, hdc)
return float(dpi)
except Exception:
return 96.0
Add to layout.py. Use in terminal_size() or expose as detected_dpi().
This is a nice-to-have, not blocking.
Testing Strategy
What must pass before each phase merges
Phase 1+2:
uv run pytest tests/test_layout.py -v # all new tests
uv run pytest tests/ -q # full suite, no regressions
Phase 3:
# Smoke test each new flag
uv run python -m justdoit.cli "JUST DO IT" --measure
uv run python -m justdoit.cli "JUST DO IT" --target 3840x2160
uv run python -m justdoit.cli "JUST DO IT" --target 3840x2160 --save-svg /tmp/test.svg
uv run python -m justdoit.cli "JUST DO IT" --fit 40
uv run pytest tests/ -q
Phase 4:
uv run python scripts/generate_gallery.py --profile standard # must match current output
uv run python scripts/generate_gallery.py --profile wide
uv run python scripts/generate_gallery.py --profile 4k
# manual visual check of 4k SVGs in browser
uv run pytest tests/ -q
What NOT to test
- Pixel-exact SVG rendering — browser differences make this untestable in CI
- DPI auto-detection — platform-specific, test manually on Windows
- Visual column alignment at large sizes — must be eyeballed
Risk Register
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
FIGlet kerning makes measure() inaccurate |
Medium | Low | Document, don’t special-case |
| SVG column drift at 72px | Medium | Medium | Validated in Phase 4; textLength fix in 5.3 if needed |
fit_text() off-by-one at edge cases |
Low | Low | Test suite covers suffix width inclusion |
max_font_pt() scan misses non-monotone display configs |
Low | Low | Full scan (no early break) handles this |
| Gallery 4k SVGs too large for GitHub (>1MB per file) | Medium | Low | Warn in script; GitHub renders SVGs up to ~10MB |
Phase 4 --profile all slow (3× renders) |
Low | Low | Gallery is already slow; document expected time |
Dependencies Between Phases
Phase 1+2 ──────────────────→ Phase 3 (CLI)
│ │
└───────────────────────→ Phase 4 (Gallery)
│
Phase 5 (Polish)
Phase 3 and Phase 4 are independent of each other — both depend only on Phase 1+2. They can be done in either order or in the same session if time allows.
Session Execution Guide
Running Phase 1+2 (recommended first session)
- Orient:
git pull,uv run pytest tests/ -q - Create
justdoit/layout.py— implement in order:measure(),RenderTarget,DISPLAYS,fit_text(),terminal_size() - Update
justdoit/output/svg.py— makefont_sizeexplicit insave_svg() - Write
tests/test_layout.py— implement all tests from Task 1.3 - Run
uv run pytest tests/ -q— must be green - Manual SVG validation at 72px (Task 1.4)
- Commit + push
Running Phase 3 (second session)
- Orient:
git pull,uv run pytest tests/ -q - Add flags to
cli.pyin order:--measure,--target,--svg-font-size,--fit - Smoke test all four flags
- Run
uv run pytest tests/ -q - Commit + push
Running Phase 4 (third session)
- Orient:
git pull,uv run pytest tests/ -q - Add
GalleryProfileandPROFILEStogenerate_gallery.py - Refactor
_generate_for_profile() - Add
--profileflag - Generate all three profiles:
uv run python scripts/generate_gallery.py --profile all - Visually validate 4k SVGs in browser — document findings
- Run
uv run pytest tests/ -q - Commit + push (including generated gallery files)
This plan is the authoritative implementation guide. Update status inline as
phases complete. When all phases are done, archive to docs/decisions/ADR-008-size-scale-resolution.md.