Compare commits

...
2 Commits
Author SHA1 Message Date
xavierk 4550dd5111 Keep package directories accessible under restrictive build umasks
Release / release (push) Successful in 1m14s
2026-09-19 11:21:57 +05:30
xavierk e49aac8569 Release Fenris 0.5.0 with Chalktone dashboard and dotted activity plots 2026-09-19 11:13:37 +05:30
25 changed files with 1131 additions and 623 deletions
+14
View File
@@ -9,6 +9,20 @@ backfill releases from before this changelog.
## [Unreleased] ## [Unreleased]
## [0.5.0] - 2026-09-19
### Changed
- Make drive activity the focus of a Chalktone dashboard with Live, Day, and History tabs, panel zoom, and fixed monitoring controls.
- Replace block bars with labelled dotted volume plots that fit the terminal and preserve gaps, partial evidence, and point inspection.
### Fixed
- Preserve historical selections and store-fault messages through graph refresh and resize.
- Show hourly drill-down results without overwriting them with a loading placeholder.
- Keep known unallocated daily volume visible across coverage gaps, and distinguish missing hourly evidence from zero on small terminals.
- Stage package directories with consistent public permissions, avoiding openSUSE RPM conflicts and inaccessible runtime paths when built with a restrictive umask.
## [0.4.0] - 2026-09-18 ## [0.4.0] - 2026-09-18
### Added ### Added
+23
View File
@@ -252,6 +252,29 @@ is retried at the next interval; it never fabricates missing observations.
## Reading the dashboard ## Reading the dashboard
![Chalktone dashboard with a dotted activity plot](assets/dashboard-chalktone.png)
Preview uses synthetic observations, not measurements from a real drive.
The Chalktone dashboard opens with a large **Live** activity plot. **Day** shows
hourly evidence for a selected date, labelled UTC, and **History** shows daily
evidence. Local-day totals keep their recorded timezone. Dotted traces show
measured read/write volumes, not transfer speed; missing evidence breaks the
trace. `?` marks a gap, `~` a partial total, and `u` unallocated daily volume.
Use the selected-point readout for exact values and evidence state.
- `v` cycles Live / Day / History; the tabs are also clickable.
- `←` / `→` inspect points; `w` switches read/write volume in every view.
- `[` / `]` browse dates, `g` enters a date, and `t` returns to today/live.
- `Tab` / `Shift+Tab` move focus; `z` expands the focused panel, and `z` or
`Esc` restores it. Monitoring status and controls remain visible.
- `s` cycles Chalktone, Amber, Nord, and High Contrast; saved theme preferences
survive upgrades. `m` toggles reduced motion.
On smaller terminals, textual summaries and scrollable panels keep evidence
accessible. Pause, resume, collect, disclosures, help, and quit remain available
in the fixed control row.
Run `fenris` as your normal user to open the TUI dashboard. The dashboard does Run `fenris` as your normal user to open the TUI dashboard. The dashboard does
not need `sudo`. Use `sudo` for package installation and system configuration; not need `sudo`. Use `sudo` for package installation and system configuration;
pause, resume, and collect-now actions normally authenticate through polkit. pause, resume, and collect-now actions normally authenticate through polkit.
Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

+67
View File
@@ -0,0 +1,67 @@
# Glint dashboard design adoption
Research date: 2026-09-19. Status: feasibility findings with interview decisions in progress. This note does not authorize implementation or change Fenris's accepted behavior.
## Finding
Fenris can adopt a Glint-inspired terminal dashboard through an independently authored redesign of its existing Textual presentation layer. Glint is a Rust terminal application, not an HTML/CSS dashboard or a drop-in Textual component library. Its most relevant ideas are a compact pane grid, consistent title rows and metadata, obvious focus, restrained colors, and enlarging a focused pane. A wholesale Glint integration would introduce a different UI runtime, unrelated application infrastructure, and a licensing decision without being necessary to achieve this visual direction.
The user has selected **Chalktone / screenshot 3**, **a large activity chart as the primary panel**, and **panel styling plus keyboard focus and zoom**. The activity panel has **Live / Day / History tabs**; enlarging a panel retains a **fixed monitoring status, freshness, and control strip**. A general dashboard builder is outside the selected scope. The developing product specification is [Glint dashboard design](../spec/glint-dashboard-design.md).
## Primary-source snapshot
Inspected Glint's default branch at commit [`c1d73d3e8ead2f4069630b2a237306af8f6e69c8`](https://github.com/ntrospect0/glint/tree/c1d73d3e8ead2f4069630b2a237306af8f6e69c8), committed 2026-07-19. A shallow reference checkout was created outside Fenris at `/tmp/fenris-glint-reference`.
- [README](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/README.md) contains three dashboard screenshots, a setup screenshot, and a live-capture link. The first two dashboard screenshots use `tokyonight`; the third uses `chalktone`. They show example compositions, not one mandatory layout.
- [Screenshot 1](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/screenshots/glint-demo1.png), [screenshot 2](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/screenshots/glint-demo2.png), [screenshot 3](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/screenshots/glint-demo3.png), and [setup screenshot](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/screenshots/glint-setup.png) are versioned in the repository. This source investigation does not substitute for visual inspection of those images.
- [Cargo.toml](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/Cargo.toml) declares Rust edition 2021, package version 0.5.0, Ratatui 0.28, Crossterm 0.28, and a `glint` binary. It also brings Tokio, HTTP clients, configuration/watch infrastructure, and optional widget dependencies.
- [GitHub repository API](https://api.github.com/repos/ntrospect0/glint), checked on the research date, reports an unarchived repository created 2026-05-27. [GitHub releases API](https://api.github.com/repos/ntrospect0/glint/releases) returned no releases. The [changelog](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/CHANGELOG.md) labels 0.5.0 and 0.4.0 unreleased, and README installation is from source. This is evidence of a young project and its distribution state, not proof that it is abandoned.
- [CI configuration](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/.github/workflows/ci.yml) runs Cargo tests and Clippy on Ubuntu; Clippy warnings do not fail CI. Source review alone does not establish that current CI passes. No Glint build or test run was performed for this assessment.
## What can transfer
| Glint pattern and source | Fit for Fenris | Scope implication |
| --- | --- | --- |
| Pane grid with row/column spans: [layout model](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/config/layout.rs), [default configuration](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/config/defaults/config.toml). | Fenris already uses a Textual grid and rounded bordered panes. A considered re-layout can use that existing ownership. | Decide which Fenris facts deserve persistent space. A fixed layout is much smaller than Glint's user-composable dashboard system. |
| Title integrated into the border, right-aligned metadata, focus treatment, shortcut indication: [UI title helpers](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/ui/mod.rs). | Useful for clearly named activity, history, drive, and monitoring panes. Date/range/freshness can become concise pane metadata where legible. | Independently implement the visible behavior in Fenris; do not translate or copy these GPL helpers. Ensure essential evidence is not merely truncated away. |
| Semantic theme roles separating border, title, metadata, and text: [theme model](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/theme/mod.rs), [bundled schemes](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/config/defaults/colorschemes.toml). | Fenris already owns themes in `src/fenris/themes.py`. Extend that role system for a chosen visual direction while keeping semantic status colors and text. | Select the preferred screenshot/palette before making the default. High Contrast and reduced-motion preferences already exist and must remain usable. |
| Tab/click focus, keyboard shortcuts, and focused-pane enlargement: [README controls](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/README.md), [app focus/zoom ownership](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/app.rs). | Particularly useful for graphs when a small terminal limits detail. | Zoom is new interaction behavior, not just styling. Selection/date/focus must survive entry, exit, resize, and refresh; essential fault/pause state needs a visibility decision. |
| Multiple widgets stacked in one cell, cycled with `.`/`,`: [stack implementation](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/widgets/stack.rs). | A possible way to expose secondary details without permanent screen cost. | Hiding monitoring state, confidence, or evidence behind inactive tabs would weaken Fenris's current contract. Choose deliberately; not assumed needed. |
| Responsive views exposing more detail at larger sizes: [view tiers](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/widgets/view_tier.rs), [widget author guide](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/widget-sdk.md). | Useful principle, but Glint's breakpoints describe its content and are not Fenris requirements. | Preserve Fenris's constrained-terminal facts and controls, then design larger views. Do not simply reuse Glint's threshold values. |
| Compact bottom status bar: [status bar](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/ui/status_bar.rs). | Supports a quieter action/focus rail. | Fenris has real monitoring continuity, freshness, collection outcome, authentication, and quit semantics. Its footer cannot be reduced to Glint's version/clock/theme bar. |
Glint's calendar, stocks, news, email, weather, notes, galleries, external credentials, and profile/setup machinery are outside the stated Fenris redesign. Its widget SDK adds widgets inside the Rust application via a trait and registry; it is not an existing Python integration seam ([widget SDK](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/widget-sdk.md), [widget interface](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/widgets/mod.rs)).
## Fenris ownership and constraints
The present application is Python with Textual, pinned to Textual 8.2.8 in [`requirements.txt`](../../requirements.txt). [`FenrisTuiApp`](../../src/fenris/tui.py) already owns the grid, header, bordered panes, compact layout, help, action bindings, and theme registration; `DailyBarGraph` and `LiveActivityGraph` already own graph selection/rendering. [`themes.py`](../../src/fenris/themes.py) owns Amber, Nord, High Contrast, and status color precedence. These are the natural extension points; a new backend or service is unnecessary for a visual redesign.
The design must preserve these existing responsibilities and evidence semantics:
- The collector owns privileged drive acquisition and observation-store writes; the TUI is an unprivileged reader and dispatches administrative actions through the sanctioned helper. A visual redesign does not imply a second sampler or store writer. Sources: [`CONTEXT.md`](../../CONTEXT.md), [redesign spec](../spec/fenris-redesign.md), [`tui.py`](../../src/fenris/tui.py).
- Usage-adjusted theoretical lifespan remains a write-endurance projection, accompanied by projection confidence and supporting facts. It must not become a physical failure countdown. Unknown, unavailable, warming, stale, and store-fault states require meaningful presentation. Sources: [`CONTEXT.md`](../../CONTEXT.md), [projection ADR](../adr/0002-projection-model-sustained-regime.md), [live activity specification](../spec/live-drive-activity.md).
- Gaps must not become zeros, and unavailable measurements must not become decorative smooth curves. Local-day labels, read/write units, incomplete evidence, selected dates, and timezone context matter. Sources: [local-day history ADR](../adr/0010-local-day-activity-history.md), [live activity specification](../spec/live-drive-activity.md).
- Pause is a deliberate disable; quitting only leaves the TUI. Monitoring continuity, last collection outcome, and freshness must remain understandable. Sources: [service lifecycle ADR](../adr/0003-service-lifecycle-and-sanctioned-toggle.md), [dashboard clarity specification](../spec/dashboard-clarity.md), [`status_composition.py`](../../src/fenris/status_composition.py).
- The current source switches to constrained presentation below 80 columns or 24 rows; the accepted live-activity specification requires daily totals, dates, evidence labels, confidence, and controls to remain accessible on narrow terminals. A new design must be reviewed against real target dimensions. Sources: [`tui.py`](../../src/fenris/tui.py), [live activity specification](../spec/live-drive-activity.md).
The fixed Panes information architecture is normative in the older [redesign specification, section 7](../spec/fenris-redesign.md), and [dashboard clarity](../spec/dashboard-clarity.md) says it preserves that architecture and prescribes a separate quit rail. A significantly different pane hierarchy or footer should explicitly amend those presentation decisions when the user selects the new design, rather than silently treating old contracts as irrelevant. The underlying evidence and action semantics can be retained.
## License boundary
Glint declares **GPL-3.0-or-later** in [Cargo.toml](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/Cargo.toml) and source SPDX headers. Its [README license section](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/README.md) says distributed modified versions must remain GPL-licensed with copyright notices, and its [LICENSE](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/LICENSE) specifies the distribution obligations. Fenris deliberately uses MIT across source and packages under [ADR 0009](../adr/0009-mit-license.md).
Copying or translating Glint implementation into an integrated distributed Fenris would therefore raise GPL compliance and licensing choices that conflict with keeping the combined implementation solely MIT. It is not an adoption route to take implicitly. The recommended route is independently authored Fenris UI code using common layout/interaction ideas observed in Glint, without copying Glint code or assets. If direct implementation reuse becomes a requirement, resolve licensing or separate permission first; this research does not establish that permission.
## Interview state
The selected direction is an independently implemented Chalktone-inspired dashboard with a fixed Fenris layout, an activity-first hierarchy, keyboard focus and enlargement, one activity panel with Live / Day / History tabs, and a persistent monitoring status/freshness/control strip during enlargement. Existing 80×24 support and constrained reflow are retained rather than reopened as a new minimum-size decision. The [developing design specification](../spec/glint-dashboard-design.md) owns the precise requirements and any remaining decisions; this research note is supporting evidence.
No product code, glossary, ADR, or existing acceptance criteria were changed by this investigation. Keyboard focus/zoom feasibility does not establish that exact interaction behavior has been implemented or runtime-validated in Fenris.
## Validation scope
Evidence comes from the pinned Glint source, its checked-in documentation/screenshots, GitHub's first-party API, and Fenris's current source and accepted documents. The main design investigation visually inspected screenshots 1 and 3 and separately verified Textual grid documentation.
The main investigation also reviewed synthetic renders of current Fenris at 140×44 and 80×24, after refresh cleared the launch authentication banner, using installed Textual 8.2.7 (the repository lock is 8.2.8). The captures are `/tmp/fenris-current-capture-3xdv46jm/fenris-current-140x44.png` and `/tmp/fenris-current-capture-3xdv46jm/fenris-current-80x24.png`. They showed a large blank vertical area; at 80×24 the drive, monitoring status, and actions fell below the initial viewport. This supports rearranging the panels and keeping the status/control strip visible, but is not a diagnosis of the deployed drive or proof across every state.
No Glint runtime was executed, and no Glint asset was copied into the product. No accessibility audit was conducted. This research establishes architectural feasibility and records decisions; it does not claim pixel fidelity, a working integration, an upstream test result, or a measured performance result.
+6
View File
@@ -1,5 +1,11 @@
# Fenris dashboard clarity specification # Fenris dashboard clarity specification
**Presentation amendment (2026-09-19):** The accepted
[Glint-inspired dashboard design](glint-dashboard-design.md) supersedes the
preserved Panes arrangement and standalone heavy quit rail. Identity, continuity,
paused-state explanations, and the distinction between quitting and pausing
remain required; quit now has an explicit entry in the fixed controls.
**Status: decision-complete.** Assembled by [Assemble the dashboard clarity specification and close the map](https://git.bongbetic.com/xavierk/Fenris/issues/60) from the closed tickets of the Wayfinder map [Chart Fenris dashboard clarity](https://git.bongbetic.com/xavierk/Fenris/issues/55). This document is normative for the follow-up **execution effort**; nothing here is implemented by the map. **Status: decision-complete.** Assembled by [Assemble the dashboard clarity specification and close the map](https://git.bongbetic.com/xavierk/Fenris/issues/60) from the closed tickets of the Wayfinder map [Chart Fenris dashboard clarity](https://git.bongbetic.com/xavierk/Fenris/issues/55). This document is normative for the follow-up **execution effort**; nothing here is implemented by the map.
**Canonical roles.** The [redesign specification](fenris-redesign.md) (frozen) and [ADRs 0001–0007](../adr/) remain authoritative and untouched — this is a companion spec covering five dashboard clarity additions plus the changelog-driven release-notes mechanism. The [criteria register](acceptance-criteria.md) carries the testable statements: **DC-1–DC-8**, appended by this assembly, with **TUI-4's binding list amended** (§4). Terminology follows the glossary in [`CONTEXT.md`](../../CONTEXT.md), including *Deliberate disable* and *Release*. **Canonical roles.** The [redesign specification](fenris-redesign.md) (frozen) and [ADRs 0001–0007](../adr/) remain authoritative and untouched — this is a companion spec covering five dashboard clarity additions plus the changelog-driven release-notes mechanism. The [criteria register](acceptance-criteria.md) carries the testable statements: **DC-1–DC-8**, appended by this assembly, with **TUI-4's binding list amended** (§4). Terminology follows the glossary in [`CONTEXT.md`](../../CONTEXT.md), including *Deliberate disable* and *Release*.
+6
View File
@@ -362,6 +362,12 @@ Never "82 % confidence" or "95 % accurate".
## 7. Panes TUI ## 7. Panes TUI
**Presentation amendment (2026-09-19):** The accepted
[Glint-inspired dashboard design](glint-dashboard-design.md) supersedes this
section's panel arrangement and graph appearance with an activity-first layout,
dotted volume plots, Live / Day / History tabs, and focused-panel zoom. Shared
evidence, projection, authentication, and monitoring-action contracts still apply.
**Decisions:** [Prototype the TUI information architecture](https://git.bongbetic.com/xavierk/Fenris/issues/3) (Variant A adopted), [Evaluate Python TUI frameworks](https://git.bongbetic.com/xavierk/Fenris/issues/6) (Textual). **ADRs:** [0003](../adr/0003-service-lifecycle-and-sanctioned-toggle.md) §§8, 10; [0004](../adr/0004-install-upgrade-removal-lifecycle.md) §10. **Criteria:** TUI-1–TUI-4, CI-1, CI-2, CI-4. The [prototype](https://git.bongbetic.com/xavierk/Fenris/src/branch/prototype/tui-information-architecture/prototype/tui-ia) is visual reference only; this section is normative. **Decisions:** [Prototype the TUI information architecture](https://git.bongbetic.com/xavierk/Fenris/issues/3) (Variant A adopted), [Evaluate Python TUI frameworks](https://git.bongbetic.com/xavierk/Fenris/issues/6) (Textual). **ADRs:** [0003](../adr/0003-service-lifecycle-and-sanctioned-toggle.md) §§8, 10; [0004](../adr/0004-install-upgrade-removal-lifecycle.md) §10. **Criteria:** TUI-1–TUI-4, CI-1, CI-2, CI-4. The [prototype](https://git.bongbetic.com/xavierk/Fenris/src/branch/prototype/tui-information-architecture/prototype/tui-ia) is visual reference only; this section is normative.
### 7.1 Framework and floor ### 7.1 Framework and floor
+148
View File
@@ -0,0 +1,148 @@
# Glint-inspired Fenris dashboard
Status: accepted on 2026-09-19. The user confirmed the complete design and
additionally requested Glint-style plotted graphs in place of block bars.
## Purpose and reference
Adopt the visual approach of [Glint](https://github.com/ntrospect0/glint) for
Fenris's terminal dashboard. The selected visual reference is the third README
screenshot, using Chalktone, at upstream commit
`c1d73d3e8ead2f4069630b2a237306af8f6e69c8`:
[reference screenshot](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/screenshots/glint-demo3.png).
The source and architectural assessment is recorded in
[the research note](../research/glint-dashboard-adoption.md).
## Confirmed user choices
- Adopt panel styling, keyboard focus, and panel zoom. A freely configurable
dashboard builder is outside the selected scope.
- Give the live read/write activity chart the largest opening-screen area,
with endurance outlook and monitoring state visible alongside it.
- Use the muted Chalktone appearance from Glint's third screenshot as the
visual direction.
- Put Live / Day / History views inside one activity panel, keeping both
read/write totals and the selected date visible.
- Expand a focused panel within the dashboard while retaining a fixed strip
for monitoring state, observation freshness, and essential controls.
- Replace block bars with thin dotted time-series plots resembling Glint's
chart. Label volume and time axes, retain exact selected-point readouts,
and leave explicit breaks for missing or incompatible evidence. Joining
adjacent measured points is visual guidance, not additional observations.
## Implementation recommendation
Implement the selected visual and interaction patterns independently in
Fenris's existing Python/Textual presentation layer. Retain the collector,
observation store, shared status/projection contracts, and authenticated control
path. Glint is a Rust/Ratatui application under GPL-3.0-or-later; copying or
porting its implementation into Fenris would require a separate licensing
decision under [ADR 0009](../adr/0009-mit-license.md). Referencing general panel
and navigation patterns does not require adopting its application architecture.
Use thin borders, compact titles, an explicit focused-panel indicator, and
restrained cream/earth-tone accents. Preserve readable contrast and semantic
state labels; the reference's dim secondary text is not a readability target.
Use Fenris content and identity, without Glint's unrelated clock, weather,
finance, email, or gallery features.
## Existing behavior to preserve
- Activity is measured read/write volume, with both totals accessible and
writes selected initially. A visual stock-chart reference must not turn
interval volume into speed or imply continuity through unknown evidence.
- Preserve the latest-three-hour opening view, date selection, local-day and
timezone labels, available history precision, and historical selection across
refresh under [live drive activity](live-drive-activity.md).
- Preserve the usage-adjusted theoretical lifespan and categorical projection
confidence with contributing facts. Navigation and graph selection cannot
change the projection evidence window or endurance accounting.
- Preserve explicit zero, gap, incomplete, unallocated, unavailable, paused,
stale, collection-failure, and store-fault states.
- Keep boot enablement, runtime activity, collection outcome, and observation
freshness distinct. Quit leaves background monitoring running; pause remains
a deliberate disable through the existing control path.
- Preserve keyboard and mouse access, constrained-terminal text/reflow,
high-contrast availability, reduced motion, help, and disclosures.
## Concrete layout and interaction proposal
- A compact identity header above one dashboard workspace.
- A narrow supporting column for the endurance outlook and drive facts;
a wide activity panel receives the remaining workspace. On the normal
dashboard, confidence and monitoring state remain visible beside activity.
- Live starts with the latest three hours; Day exposes the selected local
day's available detail; History exposes the retained longer-term evidence.
Reuse existing measurements and ranges rather than introducing a new data
model. Show selected date/timezone, read/write totals, measurement, and
evidence state wherever applicable.
- Use visible, clickable tabs; Tab/Shift+Tab and mouse clicks move focus.
Use `z` to toggle focused-panel zoom and Escape to restore the dashboard
when an input/dialog is not consuming Escape. Preserve selection, date,
range, measurement, and focus across zoom and refresh.
- Keep the bottom status/control area outside the expanding workspace. It
may wrap when required: compactness cannot merge the four service facts or
hide a fault/pause state. Keep quit distinct from pause in wording and
behavior without spending three full rows on a separate heavy quit box.
- Use existing date-navigation and read/write shortcuts, with visible help
updated for tabs, focus, and zoom. Text entry must consume its own keys.
- Use Chalktone-inspired styling as the new default while retaining existing
selectable themes, High Contrast, and reduced motion. Preserve an explicitly
saved theme preference during upgrade.
- Design for 80×24 and larger, with existing text/reflow behavior below that
size. Essential facts and actions must remain accessible; the large-screen
reference does not require squeezing its entire density into small terminals.
These defaults implement the confirmed choices. Date entry and inspection
retain existing evidence precision: hourly/daily UTC evidence is labelled UTC,
while local-day totals keep their recorded timezone. This visual redesign does
not manufacture finer or local-hour precision from coarse UTC evidence.
## Smallest sufficient implementation proof
Render normal and zoomed views at a representative large terminal and 80×24,
plus a constrained terminal. Check focus/tab/zoom/date/read-write interactions
and selection persistence through refresh. Exercise paused, stale, store-fault,
missing-baseline, and incomplete-evidence displays using existing synthetic
stores and headless TUI patterns. Verify quit never invokes monitoring control
and existing action tests still cover the sanctioned helper. UI fixtures are
not evidence of the user's deployed drive state.
## Specification reconciliation
This accepted redesign supersedes conflicting presentation requirements in
[the original Panes specification](fenris-redesign.md#7-panes-tui) and
[dashboard clarity](dashboard-clarity.md): the full-width headline becomes a
supporting endurance panel, the history/live plots share a tabbed activity
panel, and a compact fixed control row replaces the heavy standalone quit
rail. The block-bar requirement is superseded by dotted volume plots. Their
behavioral requirements, including clear quit-versus-pause semantics, remain.
The live-drive-activity specification excludes unrelated redesign from that
earlier task. This is a separate design request, not permission to undo its
accepted data, date-navigation, evidence, or forecast behavior. Existing design
documents and source may differ in implementation status; this assessment is
not proof that every earlier acceptance criterion has shipped.
## Documentation scope
No new domain term has been resolved: panel, focus, zoom, and theme are general
interface concepts and do not belong in the domain glossary. No new ADR is
needed for a reversible presentation change that retains the existing stack,
license, data model, and privilege boundary. Record any later durable
architectural trade-off separately if one emerges.
## Implementation validation
Implemented in Fenris's existing presentation layer with Textual 8.2.8.
The full suite passed 801 tests; 43 packaging/signing checks were skipped for
missing package artifacts or signing tools. After the final incomplete-evidence
fixes, all 81 focused dashboard, history, and plot tests passed. New renderer
and regression files pass configured Ruff checks; affected production files
pass correctness lint and the diff passes whitespace checks. Existing broader
lint warnings were not part of this redesign.
Rendered normal and zoomed dashboards at 140×44, plus normal 80×24 and
constrained 70×20 views. The [saved preview](../../assets/dashboard-chalktone.png)
uses synthetic observations. No installation or release was performed.
+4
View File
@@ -14,6 +14,10 @@
# /usr/lib/tmpfiles.d/fenris.conf # /usr/lib/tmpfiles.d/fenris.conf
set -euo pipefail set -euo pipefail
# Package directories must be traversable by every runtime user and agree
# with distribution-owned directories, regardless of the builder's umask.
umask 022
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
STAGE_DIR="${REPO_ROOT}/build/stage" STAGE_DIR="${REPO_ROOT}/build/stage"
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "fenris" name = "fenris"
version = "0.4.0" version = "0.5.0"
description = "NVMe wear monitor with persistent TUI" description = "NVMe wear monitor with persistent TUI"
requires-python = ">=3.10" requires-python = ">=3.10"
license = {file = "LICENSE"} license = {file = "LICENSE"}
+1 -1
View File
@@ -1,2 +1,2 @@
"""Fenris: NVMe wear monitor with persistent TUI.""" """Fenris: NVMe wear monitor with persistent TUI."""
__version__ = "0.4.0" __version__ = "0.5.0"
+104
View File
@@ -0,0 +1,104 @@
"""A terminal volume plot shared by Fenris's activity views.
Braille provides two by four dots per terminal cell. Only adjacent, complete
measurements are connected; gaps and partial evidence never imply continuity.
The returned columns also place mouse inspection on the plotted time axis.
"""
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from rich.text import Text
@dataclass(frozen=True)
class VolumePoint:
time: float
volume: int | None
label: str
state: str = "measured"
def volume_plot(
points: Sequence[VolumePoint], width: int, height: int,
selected: int, colors: Mapping[str, str],
) -> tuple[Text, list[int], str]:
"""Render bounded axes and a dotted trace, without resampling evidence."""
width, height = max(12, width), max(4, height)
axis_width = 8
columns, rows = width - axis_width, height - 2
pixel_width, pixel_height = columns * 2, rows * 4
maximum = max((p.volume or 0 for p in points), default=0)
scale, unit = (1e12, "TB") if maximum >= 1e12 else (
(1e9, "GB") if maximum >= 1e9 else
(1e6, "MB") if maximum >= 1e6 else
(1e3, "KB") if maximum >= 1e3 else (1, "B")
)
ceiling = maximum or scale
start = points[0].time if points else 0
span = (points[-1].time - start) if len(points) > 1 else 0
xs = [round((p.time - start) / span * (pixel_width - 1)) if span else 0
for p in points]
cells = [[0] * columns for _ in range(rows)]
# Unicode braille dot numbering, indexed by y within cell and then x.
bits = ((1, 8), (2, 16), (4, 32), (64, 128))
def dot(x: int, y: int) -> None:
cells[y // 4][x // 2] |= bits[y % 4][x % 2]
previous = None
markers = {}
for index, (point, x) in enumerate(zip(points, xs)):
if point.volume is None:
previous = None
if point.state != "future":
markers[x // 2] = "?"
continue
y = round((1 - point.volume / ceiling) * (pixel_height - 1))
y = min(pixel_height - 1, max(0, y))
if previous is not None and point.state == "measured":
px, py = previous
steps = max(abs(x - px), abs(y - py), 1)
for step in range(steps + 1):
dot(round(px + (x - px) * step / steps),
round(py + (y - py) * step / steps))
dot(x, y)
previous = (x, y) if point.state == "measured" else None
if point.state != "measured":
markers[x // 2] = "~" if point.state == "partial" else "u"
elif point.volume == 0:
markers.setdefault(x // 2, "·")
selected_column = xs[selected] // 2 if 0 <= selected < len(xs) else -1
result = Text(no_wrap=True, overflow="crop")
ticks = {0, rows // 2, rows - 1}
for row, values in enumerate(cells):
value = ceiling * (rows - 1 - row) / max(1, rows - 1) / scale
label = f"{value:6.2f}"[-6:] if row in ticks else " "
result.append(label + " │", style=colors["muted"])
for col, value in enumerate(values):
char = chr(0x2800 + value) if value else " "
style = colors["allocated"]
if col == selected_column:
style = "bold " + colors["selection"]
if not value:
char = "┊"
style = colors["muted"]
result.append(char, style=style)
result.append("\n")
result.append(" └", style=colors["muted"])
for col in range(columns):
result.append("▼" if col == selected_column else markers.get(col, "─"),
style=colors["selection"] if col == selected_column else colors["muted"])
result.append("\n" + " " * axis_width)
labels = [" "] * columns
end_of_label = -1
for index in sorted({0, len(points) // 2, len(points) - 1}):
if index < 0 or not points:
continue
label = points[index].label[:columns]
column = max(0, min(columns - len(label), xs[index] // 2 - len(label) // 2))
if column > end_of_label:
labels[column:column + len(label)] = label
end_of_label = column + len(label)
result.append("".join(labels), style=colors["muted"])
return result, [axis_width + x // 2 for x in xs], unit
+3 -3
View File
@@ -19,8 +19,8 @@ from typing import Any, Dict
PREFERENCE_FILE_NAME = "preferences.json" PREFERENCE_FILE_NAME = "preferences.json"
VALID_THEMES = {"amber", "nord", "high_contrast"} VALID_THEMES = {"chalktone", "amber", "nord", "high_contrast"}
DEFAULT_THEME = "amber" DEFAULT_THEME = "chalktone"
DEFAULT_REDUCED_MOTION = False DEFAULT_REDUCED_MOTION = False
@@ -46,7 +46,7 @@ def load_preferences() -> Dict[str, Any]:
reduced_motion: bool reduced_motion: bool
If the file is missing, corrupt, unreadable, or contains invalid If the file is missing, corrupt, unreadable, or contains invalid
values, returns safe defaults (Amber theme, normal motion). values, returns safe defaults (Chalktone theme, normal motion).
""" """
path = get_preference_path() path = get_preference_path()
+20 -4
View File
@@ -1,6 +1,6 @@
"""Fenris theme presets (issue #80). """Fenris theme presets (issue #80).
Three accessible colour presets: Amber (default), Nord, and High Contrast. Chalktone-inspired default plus Amber, Nord, and High Contrast.
Themes style chrome, borders, accents, muted text, and graph roles; Themes style chrome, borders, accents, muted text, and graph roles;
status semantic colours/glyphs/text always win. status semantic colours/glyphs/text always win.
@@ -126,7 +126,22 @@ _HIGH_CONTRAST = Theme(
# Theme registry # Theme registry
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_CHALKTONE = Theme(
name="fenris-chalktone",
primary="#abc4b3", secondary="#c9b69a", accent="#dfc49a",
warning="#e9bc79", error="#e58d89", success="#acd29c",
foreground="#e0d8c5", background="#202426", surface="#202426",
panel="#252a2c", boost="#333b3d", dark=True,
variables={
"graph-allocated": "#abc4b3", "graph-unallocated": "#dfc49a",
"graph-gap": "#e58d89", "graph-zero": "#a7b1a9",
"graph-partial": "#e9bc79", "graph-selection": "#f3dbb0",
"border-default": "#606d6a", "muted-text": "#a7b1a9",
},
)
THEMES = { THEMES = {
"chalktone": _CHALKTONE,
"amber": _AMBER, "amber": _AMBER,
"nord": _NORD, "nord": _NORD,
"high_contrast": _HIGH_CONTRAST, "high_contrast": _HIGH_CONTRAST,
@@ -138,16 +153,16 @@ THEME_NAMES = set(THEMES.keys())
def get_theme(name: str) -> Theme: def get_theme(name: str) -> Theme:
"""Return a registered theme by preset name. """Return a registered theme by preset name.
Unknown names fall back to Amber. Unknown names fall back to Chalktone.
""" """
return THEMES.get(name, _AMBER) return THEMES.get(name, _CHALKTONE)
def get_graph_colors(theme_name: str) -> Dict[str, str]: def get_graph_colors(theme_name: str) -> Dict[str, str]:
"""Return the graph colour roles for a theme preset. """Return the graph colour roles for a theme preset.
Returns a dict with keys: allocated, unallocated, gap, zero, partial, Returns a dict with keys: allocated, unallocated, gap, zero, partial,
selection. Falls back to Amber for unknown names. selection and muted text. Falls back to Chalktone for unknown names.
""" """
theme = get_theme(theme_name) theme = get_theme(theme_name)
variables = theme.variables or {} variables = theme.variables or {}
@@ -158,4 +173,5 @@ def get_graph_colors(theme_name: str) -> Dict[str, str]:
"zero": variables.get("graph-zero", "#665533"), "zero": variables.get("graph-zero", "#665533"),
"partial": variables.get("graph-partial", "#aa8822"), "partial": variables.get("graph-partial", "#aa8822"),
"selection": variables.get("graph-selection", "#ffd54f"), "selection": variables.get("graph-selection", "#ffd54f"),
"muted": variables.get("muted-text", "#a7b1a9"),
} }
+353 -511
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
"""Visible geometry and evidence boundaries of the terminal volume plot."""
from fenris.activity_plot import VolumePoint, volume_plot
from fenris.themes import get_graph_colors
def plot(points, selected=-1, width=48, height=10):
return volume_plot(points, width, height, selected, get_graph_colors("chalktone"))
def test_trace_fits_viewport_and_uses_time_not_sample_index():
points = [VolumePoint(t, v, str(t)) for t, v in ((0, 0), (3, 50), (30, 100))]
text, columns, unit = plot(points)
assert unit == "B"
assert len(text.plain.splitlines()) == 10
assert all(len(line) <= 48 for line in text.plain.splitlines())
assert columns[1] - columns[0] < (columns[2] - columns[0]) / 5
assert any(0x2801 <= ord(c) <= 0x28ff for c in text.plain)
assert not any(c in text.plain for c in "█▒░")
assert "100.00" in text.plain and "0.00" in text.plain
def test_gap_breaks_trace_while_measured_zero_stays_on_axis():
text, columns, _ = plot([
VolumePoint(0, 0, "00:00"), VolumePoint(1, None, "00:03", "gap"),
VolumePoint(2, 100, "00:06"),
])
rows = text.plain.splitlines()
assert "?" in rows[-2]
# No invented intermediate dots on either side of the missing measurement.
for row in rows[:-2]:
assert all(c == " " for c in row[columns[0] + 1:columns[-1]])
assert 0x2801 <= ord(rows[-3][columns[0]]) <= 0x28ff
def test_partial_and_unallocated_totals_are_isolated_and_labelled():
text, columns, unit = plot([
VolumePoint(0, 1_000_000, "01", "partial"),
VolumePoint(1, 2_000_000, "02", "unallocated"),
VolumePoint(2, 3_000_000, "03"),
])
assert unit == "MB"
assert "~" in text.plain and "u" in text.plain
for row in text.plain.splitlines()[:-2]:
assert all(c == " " for c in row[columns[0] + 1:columns[1]])
def test_many_intervals_and_selection_fit_small_plot_without_losing_points():
points = [VolumePoint(i, i % 9 * 1_000_000, str(i)) for i in range(180)]
text, columns, _ = plot(points, selected=100, width=32, height=6)
assert len(columns) == len(points)
assert text.plain.splitlines()[-2][columns[100]] == "▼"
assert all(len(row) == 32 for row in text.plain.splitlines())
+176
View File
@@ -0,0 +1,176 @@
"""User-facing navigation, zoom, and plotted volume in the redesigned dashboard."""
from datetime import datetime, timedelta, timezone
from xml.etree import ElementTree
import pytest
from test_tui import (
_insert_baseline,
_insert_day,
_insert_local_day,
_insert_segment,
_open_period,
)
from fenris.store import init_store
from fenris.tui import FenrisTuiApp
NOW = datetime(2026, 9, 19, 12, tzinfo=timezone.utc)
@pytest.fixture
def dashboard(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "prefs"))
class Clock(datetime):
@classmethod
def now(cls, tz=None):
return NOW
monkeypatch.setattr("fenris.tui.datetime", Clock)
monkeypatch.setattr("fenris.status.query_service_state", lambda: {
"boot_enabled": True, "timer_active": True, "last_collect_ok": True,
"last_collect_age_s": 0, "last_collect_reason": None,
})
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_insert_baseline(conn)
_open_period(conn)
for offset in range(18):
date = (NOW - timedelta(days=offset)).date().isoformat()
_insert_day(conn, date, bw=(offset + 1) * 1_000_000_000)
_insert_local_day(conn, date, bw=(offset + 1) * 1_000_000_000, br=2_000_000_000)
for index in range(61):
conn.execute(
"INSERT INTO samples (ts, device, bytes_written, bytes_read, segment_id) VALUES (?, ?, ?, ?, ?)",
((NOW - timedelta(minutes=(60 - index) * 3)).isoformat(),
"/dev/test", index * 1_000_000, index * 2_000_000, 1),
)
conn.execute(
"INSERT INTO hour_observations (hour, bytes_written_delta, bytes_read_delta, coverage, sample_count) "
"VALUES (?, ?, ?, ?, ?)", ("2026-09-18T12:00:00+00:00", 4_000_000, 8_000_000, 1, 20),
)
conn.commit()
conn.close()
return FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
def visible(app):
return " ".join("".join(ElementTree.fromstring(app.export_screenshot()).itertext()).split())
@pytest.mark.asyncio
@pytest.mark.parametrize("size", [(140, 44), (80, 24)])
async def test_live_plot_inspection_zoom_refresh_and_resize(dashboard, size):
app = dashboard
async with app.run_test(size=size) as pilot:
app.on_refresh_tick()
await pilot.pause()
assert app.theme == "fenris-chalktone"
graph = app.query_one("#live-activity")
assert app.focused is graph
await pilot.press("left", "w")
selected = str(app.query_one("#live-readout").render())
assert "11:54" in selected and "W 0.001 GB" in selected and "R 0.002 GB" in selected
assert "Reads" in str(app.query_one("#live-legend").render())
await pilot.press("z")
app.on_refresh_tick()
await pilot.pause()
assert str(app.query_one("#live-readout").render()) == selected
assert app.query_one("#activity-panel").region.width == size[0]
for fact in ("Freshness:", "Last collect:", "Boot:", "Timer:", "q Quit TUI"):
assert fact in visible(app)
await pilot.resize_terminal(100, 30)
await pilot.press("escape")
assert str(app.query_one("#live-readout").render()) == selected
assert app._zoomed_panel is None
assert app.query_one("#action-rail").region.bottom <= 30
@pytest.mark.asyncio
async def test_tabs_date_entry_and_hourly_inspection_keep_context(dashboard):
app = dashboard
async with app.run_test(size=(100, 36)) as pilot:
await pilot.click("#view-history")
await pilot.press("left", "enter")
assert app.query_one("#activity-tabs").active == "view-day"
assert "2026-09-18" in str(app.query_one("#local-day").render())
# Hour 23 is selected initially; move to the known hour 12.
await pilot.press(*(["left"] * 11))
readout = str(app.query_one("#bar-readout").render())
assert "12:00 UTC" in readout and "W 0.004 GB" in readout
await pilot.press("z", "w")
app.on_refresh_tick()
await pilot.pause()
assert str(app.query_one("#bar-readout").render()) == readout
await pilot.press("g")
await pilot.press(*list("2026-09-17"))
await pilot.press("escape")
assert str(app.query_one("#bar-readout").render()) == readout
assert app._zoomed_panel == "activity-panel"
await pilot.press("escape", "t")
assert app.query_one("#activity-tabs").active == "view-live"
@pytest.mark.asyncio
async def test_mouse_inspection_matches_time_axis(dashboard):
app = dashboard
async with app.run_test(size=(100, 36)) as pilot:
await pilot.pause()
graph = app.query_one("#live-activity")
x = graph._point_columns[0]
await pilot.click("#live-render", offset=(x, 1))
text = str(app.query_one("#live-readout").render())
assert "09:00 → 09:03 UTC" in text
@pytest.mark.asyncio
async def test_theme_control_persists_choice_and_quit_never_pauses(dashboard, monkeypatch):
calls = []
monkeypatch.setattr(dashboard, "_run_helper", lambda *args: calls.append(args))
async with dashboard.run_test(size=(80, 24)) as pilot:
await pilot.press("s")
assert dashboard.theme == "fenris-amber"
await pilot.press("q")
assert calls == []
from fenris.preferences import load_preferences
assert load_preferences()["theme"] == "amber"
@pytest.mark.asyncio
@pytest.mark.parametrize("size", [(100, 36), (70, 20)])
async def test_unallocated_volume_survives_day_with_missing_coverage(dashboard, size):
async with dashboard.run_test(size=size) as pilot:
await pilot.click("#view-history")
graph = dashboard.query_one("#usage-history")
graph.set_data([{
"day": "2026-09-18", "total_bytes": 2_000_000_000,
"unallocated_bytes": 2_000_000_000, "is_gap": True,
"is_partial": True,
}])
readout = str(dashboard.query_one("#bar-readout").render())
assert "W 2.000 GB unallocated" in readout
assert "R unavailable" in readout and "gap" in readout
if size[0] >= 80:
plot = str(dashboard.query_one("#bar-render").render())
assert any(0x2801 <= ord(c) <= 0x28ff for c in plot)
graph.measure = "read"
graph._refresh()
plot = str(dashboard.query_one("#bar-render").render())
assert not any(0x2801 <= ord(c) <= 0x28ff for c in plot)
@pytest.mark.asyncio
@pytest.mark.parametrize("state", ["gap", "future"])
async def test_small_terminal_hour_readout_does_not_invent_zero(dashboard, state):
async with dashboard.run_test(size=(70, 20)) as pilot:
await pilot.click("#view-day")
graph = dashboard.query_one("#usage-history")
graph.set_hour_data([{
"hour": "2026-09-18T12:00:00+00:00", "local_label": "12",
"is_gap": state == "gap", "is_future": state == "future",
}])
graph._hourly_selected = 0
graph._refresh_hourly()
readout = str(dashboard.query_one("#bar-readout").render())
assert "W unavailable · R unavailable" in readout
assert state in readout and "12:00 UTC" in readout
+7 -7
View File
@@ -95,7 +95,7 @@ class TestFenrisIdentity:
) )
async with app.run_test(size=(120, 24)) as pilot: async with app.run_test(size=(120, 24)) as pilot:
headline = str(app.query_one("#headline-band").render()) headline = str(app.query_one("#headline-band").render())
assert "🐺 Fenris by Bongbetic" in headline assert "🐺 Fenris by Bongbetic" in str(app.query_one("#identity").render())
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_titlebox_fallback_without_wolf(self, tmp_path): async def test_titlebox_fallback_without_wolf(self, tmp_path):
@@ -108,7 +108,7 @@ class TestFenrisIdentity:
async with app.run_test(size=(60, 24)) as pilot: async with app.run_test(size=(60, 24)) as pilot:
headline = str(app.query_one("#headline-band").render()) headline = str(app.query_one("#headline-band").render())
# Either shows wolf or fallback - both are acceptable # Either shows wolf or fallback - both are acceptable
assert "Fenris by Bongbetic" in headline assert "Fenris by Bongbetic" in str(app.query_one("#identity").render())
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_wolf_never_shows_tofu(self, tmp_path): async def test_wolf_never_shows_tofu(self, tmp_path):
@@ -140,7 +140,7 @@ class TestFenrisIdentity:
async with app.run_test(size=(120, 24)) as pilot: async with app.run_test(size=(120, 24)) as pilot:
headline = str(app.query_one("#headline-band").render()) headline = str(app.query_one("#headline-band").render())
# Identity appears once, lifespan is separate data # Identity appears once, lifespan is separate data
assert "🐺 Fenris by Bongbetic" in headline assert "🐺 Fenris by Bongbetic" in str(app.query_one("#identity").render())
assert "remaining" in headline.lower() or "projection" in headline.lower() assert "remaining" in headline.lower() or "projection" in headline.lower()
@@ -174,7 +174,7 @@ class TestSingleMakerCredit:
) )
async with app.run_test(size=(120, 24)) as pilot: async with app.run_test(size=(120, 24)) as pilot:
headline = str(app.query_one("#headline-band").render()) headline = str(app.query_one("#headline-band").render())
assert "Fenris by Bongbetic" in headline assert "Fenris by Bongbetic" in str(app.query_one("#identity").render())
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_empty_store_no_maker_credit_in_strip(self, tmp_path): async def test_empty_store_no_maker_credit_in_strip(self, tmp_path):
@@ -275,7 +275,7 @@ class TestPreservedBehavior:
"""Separate q Quit TUI rail preserved.""" """Separate q Quit TUI rail preserved."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test(size=(120, 24)) as pilot: async with app.run_test(size=(120, 24)) as pilot:
rail = str(app.query_one("#quit-rail").render()) rail = str(app.query_one("#action-rail").render())
assert "q Quit TUI" in rail assert "q Quit TUI" in rail
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -287,11 +287,11 @@ class TestPreservedBehavior:
) )
async with app.run_test(size=(120, 24)) as pilot: async with app.run_test(size=(120, 24)) as pilot:
headline = str(app.query_one("#headline-band").render()) headline = str(app.query_one("#headline-band").render())
assert "polkit" in headline.lower() assert "polkit" in str(app.query_one("#auth-notice").render()).lower()
# Should clear after first tick # Should clear after first tick
await pilot.pause(0.25) await pilot.pause(0.25)
headline_after = str(app.query_one("#headline-band").render()) headline_after = str(app.query_one("#headline-band").render())
assert "polkit" not in headline_after.lower() assert not app.query_one("#auth-notice").display
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_deliberate_pause_block_preserved(self, tmp_path): async def test_deliberate_pause_block_preserved(self, tmp_path):
+5 -5
View File
@@ -93,7 +93,7 @@ class TestPresetLoading:
"""Themes load from preferences and apply to the TUI.""" """Themes load from preferences and apply to the TUI."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_tui_applies_amber_theme_by_default(self, tmp_path): async def test_tui_applies_chalktone_theme_by_default(self, tmp_path):
"""TUI starts with the Amber theme when no preferences exist.""" """TUI starts with the Amber theme when no preferences exist."""
from fenris.tui import FenrisTuiApp from fenris.tui import FenrisTuiApp
@@ -102,7 +102,7 @@ class TestPresetLoading:
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot: async with app.run_test() as pilot:
# Theme should be fenris-amber # Theme should be fenris-amber
assert app.theme == "fenris-amber" assert app.theme == "fenris-chalktone"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_tui_applies_nord_theme_from_prefs(self, tmp_path): async def test_tui_applies_nord_theme_from_prefs(self, tmp_path):
@@ -180,7 +180,7 @@ class TestKeyBindings:
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot: async with app.run_test() as pilot:
# Start at amber # Start at amber
assert app.theme == "fenris-amber" assert app.theme == "fenris-chalktone"
# Enter drill mode on the graph # Enter drill mode on the graph
graph = app.query_one("#usage-history") graph = app.query_one("#usage-history")
@@ -382,7 +382,7 @@ class TestSafePersistence:
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot: async with app.run_test() as pilot:
# TUI should start with default theme # TUI should start with default theme
assert app.theme == "fenris-amber" assert app.theme == "fenris-chalktone"
# Dashboard should be functional # Dashboard should be functional
headline = str(app.query_one("#headline-band").render()) headline = str(app.query_one("#headline-band").render())
assert headline is not None assert headline is not None
@@ -401,7 +401,7 @@ class TestSafePersistence:
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot: async with app.run_test() as pilot:
# TUI should start without crash # TUI should start without crash
assert app.theme in ("fenris-amber", "fenris-nord", "fenris-high-contrast") assert app.theme in ("fenris-chalktone", "fenris-amber", "fenris-nord", "fenris-high-contrast")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+11 -7
View File
@@ -29,7 +29,7 @@ from fenris.local_day import (
) )
from fenris.tui import ( from fenris.tui import (
FenrisTuiApp, FenrisTuiApp,
DailyBarGraph, HistoryGraph,
_RANGE_OPTIONS, _RANGE_OPTIONS,
_RANGE_DEFAULT, _RANGE_DEFAULT,
) )
@@ -111,11 +111,12 @@ def _make_app(tmp_path, clock=None):
import fenris.tui as tui_mod import fenris.tui as tui_mod
from datetime import datetime as _real_datetime from datetime import datetime as _real_datetime
mock_dt = type("MockDatetime", (), { class MockDatetime(_real_datetime):
"now": staticmethod(lambda tz=None: clock), @classmethod
"strptime": staticmethod(lambda *a, **kw: _real_datetime.strptime(*a, **kw)), def now(cls, tz=None):
"date": staticmethod(lambda: clock.date()), return clock
})()
mock_dt = MockDatetime
patcher = patch.object(tui_mod, "datetime", mock_dt) patcher = patch.object(tui_mod, "datetime", mock_dt)
patcher.start() patcher.start()
@@ -262,7 +263,7 @@ class TestArrowInspection:
async with app.run_test(size=(100, 40)) as pilot: async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause() await pilot.pause()
graph = app.query_one("#usage-history") graph = app.query_one("#usage-history")
# Focus the graph widget explicitly await pilot.press("v", "v")
graph.focus() graph.focus()
await pilot.pause() await pilot.pause()
day_before = graph._day_data[graph.selected_index].get("day") day_before = graph._day_data[graph.selected_index].get("day")
@@ -281,6 +282,7 @@ class TestArrowInspection:
async with app.run_test(size=(100, 40)) as pilot: async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause() await pilot.pause()
graph = app.query_one("#usage-history") graph = app.query_one("#usage-history")
await pilot.press("v", "v")
await pilot.click("#usage-history") await pilot.click("#usage-history")
# Enter drill-down # Enter drill-down
@@ -329,6 +331,7 @@ class TestTodayBinding:
async with app.run_test(size=(100, 40)) as pilot: async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause() await pilot.pause()
graph = app.query_one("#usage-history") graph = app.query_one("#usage-history")
await pilot.press("v", "v")
await pilot.click("#usage-history") await pilot.click("#usage-history")
# Enter drill # Enter drill
@@ -546,6 +549,7 @@ class TestDrillDownData:
async with app.run_test(size=(100, 40)) as pilot: async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause() await pilot.pause()
graph = app.query_one("#usage-history") graph = app.query_one("#usage-history")
await pilot.press("v", "v")
await pilot.click("#usage-history") await pilot.click("#usage-history")
# Enter drill-down # Enter drill-down
+1 -1
View File
@@ -263,7 +263,7 @@ class TestLiveActivityGraph:
await pilot.pause() await pilot.pause()
header = str(graph.query_one("#live-header").render()) header = str(graph.query_one("#live-header").render())
assert "1 intervals" in header assert "1 intervals" in header
assert "w Toggle W/R" in header assert "w W/R" in str(app.query_one("#activity-tools").render())
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+43
View File
@@ -0,0 +1,43 @@
"""Package directory permissions must not inherit a builder's private umask."""
import os
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
@pytest.mark.parametrize("mask", ["077", "022"])
def test_stage_is_publicly_traversable_under_any_builder_umask(tmp_path, mask):
root = Path(__file__).resolve().parent.parent
for directory in ("packaging", "scripts", "src", "units", "polkit"):
shutil.copytree(root / directory, tmp_path / directory,
ignore=shutil.ignore_patterns("__pycache__", "*.egg-info"))
for filename in ("LICENSE", "requirements.txt"):
shutil.copy(root / filename, tmp_path / filename)
(tmp_path / "dist").mkdir()
(tmp_path / "dist/fenris-9.9.9-py3-none-any.whl").touch()
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
# Avoid downloading dependencies: emulate pip's target output while
# exercising the real staging script and its package directory layout.
pip_stub = bin_dir / "python3"
pip_stub.write_text(
f"#!{sys.executable}\n"
"import sys\nfrom pathlib import Path\n"
"target = Path(sys.argv[sys.argv.index('--target') + 1]) / 'fenris'\n"
"target.mkdir(parents=True)\n"
"(target / '__init__.py').write_text('')\n"
)
pip_stub.chmod(0o755)
subprocess.run(
["bash", "-c", f"umask {mask}; exec bash packaging/stage.sh 9.9.9"],
cwd=tmp_path, env={**os.environ, "PATH": str(bin_dir) + os.pathsep + os.environ["PATH"]},
check=True, capture_output=True, text=True,
)
stage = tmp_path / "build/stage"
for directory in (stage, *(p for p in stage.rglob("*") if p.is_dir())):
assert directory.stat().st_mode & 0o777 == 0o755, directory
assert (stage / "usr/bin/fenris").stat().st_mode & 0o777 == 0o755
assert (stage / "opt/fenris/vendor/fenris/__init__.py").stat().st_mode & 0o444 == 0o444
+8 -8
View File
@@ -3,7 +3,7 @@
Covers: Covers:
- Preference load/save with safe defaults - Preference load/save with safe defaults
- XDG_CONFIG_HOME user-scoped persistence - XDG_CONFIG_HOME user-scoped persistence
- Amber default theme, normal-motion default - Chalktone default theme, normal-motion default
- Invalid/unreadable/unwritable preference data does not crash - Invalid/unreadable/unwritable preference data does not crash
- Theme presets: Amber, Nord, High Contrast - Theme presets: Amber, Nord, High Contrast
- Reduced motion preference persistence - Reduced motion preference persistence
@@ -72,9 +72,9 @@ class TestPreferencePath:
class TestDefaults: class TestDefaults:
"""When no preference file exists, defaults are returned.""" """When no preference file exists, defaults are returned."""
def test_default_theme_is_amber(self): def test_default_theme_is_chalktone(self):
"""Amber is the default theme preset.""" """Amber is the default theme preset."""
assert DEFAULT_THEME == "amber" assert DEFAULT_THEME == "chalktone"
def test_default_reduced_motion_is_false(self): def test_default_reduced_motion_is_false(self):
"""Normal motion is the default.""" """Normal motion is the default."""
@@ -82,13 +82,13 @@ class TestDefaults:
def test_valid_themes_are_all_presets(self): def test_valid_themes_are_all_presets(self):
"""Three valid presets exist.""" """Three valid presets exist."""
assert VALID_THEMES == {"amber", "nord", "high_contrast"} assert VALID_THEMES == {"chalktone", "amber", "nord", "high_contrast"}
def test_load_returns_defaults_when_no_file(self, tmp_path): def test_load_returns_defaults_when_no_file(self, tmp_path):
"""Missing preference file returns safe defaults.""" """Missing preference file returns safe defaults."""
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(tmp_path)}): with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(tmp_path)}):
prefs = load_preferences() prefs = load_preferences()
assert prefs["theme"] == "amber" assert prefs["theme"] == "chalktone"
assert prefs["reduced_motion"] is False assert prefs["reduced_motion"] is False
@@ -151,7 +151,7 @@ class TestSafeFailures:
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}): with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences() prefs = load_preferences()
assert prefs["theme"] == "amber" assert prefs["theme"] == "chalktone"
assert prefs["reduced_motion"] is False assert prefs["reduced_motion"] is False
def test_unknown_theme_returns_default(self, tmp_path): def test_unknown_theme_returns_default(self, tmp_path):
@@ -165,7 +165,7 @@ class TestSafeFailures:
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}): with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences() prefs = load_preferences()
assert prefs["theme"] == "amber" assert prefs["theme"] == "chalktone"
def test_missing_keys_get_defaults(self, tmp_path): def test_missing_keys_get_defaults(self, tmp_path):
"""Partial preference file fills in missing keys.""" """Partial preference file fills in missing keys."""
@@ -218,7 +218,7 @@ class TestSafeFailures:
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}): with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences() prefs = load_preferences()
assert prefs["theme"] == "amber" assert prefs["theme"] == "chalktone"
def test_wrong_type_for_reduced_motion(self, tmp_path): def test_wrong_type_for_reduced_motion(self, tmp_path):
"""Non-boolean reduced_motion falls back to default.""" """Non-boolean reduced_motion falls back to default."""
+1 -1
View File
@@ -67,7 +67,7 @@ async def test_cli_tui_acquisition_parity(tmp_path, kind, service):
assert not app.query_one("#main-grid").has_class("paused") assert not app.query_one("#main-grid").has_class("paused")
await pilot.resize_terminal(60, 18) await pilot.resize_terminal(60, 18)
await pilot.pause() await pilot.pause()
assert phrase in str(app.query_one("#constrained-summary").render()).lower() assert phrase in str(app.query_one("#live-render").render()).lower()
for text in (cli, strip): for text in (cli, strip):
assert ("boot: enabled" if service == ACTIVE else "boot: disabled" if service == DISABLED else "boot: unknown") in text assert ("boot: enabled" if service == ACTIVE else "boot: disabled" if service == DISABLED else "boot: unknown") in text
assert ("timer: active" if service == ACTIVE else "timer: inactive" if service == DISABLED else "timer: unknown") in text assert ("timer: active" if service == ACTIVE else "timer: inactive" if service == DISABLED else "timer: unknown") in text
+9 -9
View File
@@ -2,7 +2,7 @@
Covers: Covers:
- Three valid presets: Amber, Nord, High Contrast - Three valid presets: Amber, Nord, High Contrast
- Amber is the default with amber graph role - Chalktone is the default; Amber retains its amber graph role
- Theme roles for graph rendering (allocated, unallocated, gap, zero, partial) - Theme roles for graph rendering (allocated, unallocated, gap, zero, partial)
- Status semantic colours/glyphs/text always win over theme - Status semantic colours/glyphs/text always win over theme
- Global action reachability and focus contrast in every preset - Global action reachability and focus contrast in every preset
@@ -29,13 +29,13 @@ from fenris.themes import (
class TestThemeRegistry: class TestThemeRegistry:
"""All three presets are registered with correct names.""" """All three presets are registered with correct names."""
def test_three_themes_registered(self): def test_four_themes_registered(self):
"""Exactly three themes exist.""" """All four presets are registered."""
assert len(THEMES) == 3 assert len(THEMES) == 4
def test_theme_names(self): def test_theme_names(self):
"""Theme names are amber, nord, high_contrast.""" """Theme names are amber, nord, high_contrast."""
assert THEME_NAMES == {"amber", "nord", "high_contrast"} assert THEME_NAMES == {"chalktone", "amber", "nord", "high_contrast"}
def test_get_theme_valid(self): def test_get_theme_valid(self):
"""get_theme returns a Theme for each valid name.""" """get_theme returns a Theme for each valid name."""
@@ -44,14 +44,14 @@ class TestThemeRegistry:
theme = get_theme(name) theme = get_theme(name)
assert isinstance(theme, Theme) assert isinstance(theme, Theme)
def test_get_theme_invalid_returns_amber(self): def test_get_theme_invalid_returns_chalktone(self):
"""Unknown theme name returns the amber theme.""" """Unknown theme name returns the amber theme."""
theme = get_theme("nonexistent") theme = get_theme("nonexistent")
assert theme.name == "fenris-amber" assert theme.name == "fenris-chalktone"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Amber theme tests (default, amber graph role) # Amber theme tests (amber graph role)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestAmberTheme: class TestAmberTheme:
@@ -179,7 +179,7 @@ class TestGraphColorRoles:
assert amber["allocated"] != nord["allocated"] or amber["allocated"] != hc["allocated"] assert amber["allocated"] != nord["allocated"] or amber["allocated"] != hc["allocated"]
def test_zero_role_is_dim(self): def test_zero_role_is_dim(self):
"""Zero-usage bars use a dim/subtle colour in all themes.""" """Zero-usage markers use a dim/subtle colour in all themes."""
for name in THEME_NAMES: for name in THEME_NAMES:
colors = get_graph_colors(name) colors = get_graph_colors(name)
# Zero should be distinct from allocated # Zero should be distinct from allocated
+64 -61
View File
@@ -40,19 +40,13 @@ from fenris.status import (
) )
from fenris.tui import ( from fenris.tui import (
FenrisTuiApp, FenrisTuiApp,
DailyBarGraph, HistoryGraph,
_format_remaining, _format_remaining,
_query_drive_health, _query_drive_health,
_query_daily_graph_data, _query_daily_graph_data,
_query_hourly_graph_data, _query_hourly_graph_data,
_RANGE_OPTIONS, _RANGE_OPTIONS,
_RANGE_DEFAULT, _RANGE_DEFAULT,
_BAR_HEIGHT,
_GLYPH_ALLOCATED,
_GLYPH_UNALLOCATED,
_GLYPH_GAP,
_GLYPH_ZERO,
_GLYPH_PARTIAL,
_MIN_WIDTH, _MIN_WIDTH,
_MIN_HEIGHT, _MIN_HEIGHT,
) )
@@ -365,15 +359,15 @@ class TestDenseScreen:
}): }):
async with app.run_test(): async with app.run_test():
strip = str(app.query_one("#service-strip").render()).lower() strip = str(app.query_one("#service-strip").render()).lower()
rail = str(app.query_one("#quit-rail").render()) rail = str(app.query_one("#action-rail").render())
usage = app.query_one("#usage-history") usage = app.query_one("#activity-panel")
service = app.query_one("#service-strip") service = app.query_one("#service-strip")
quit_rail = app.query_one("#quit-rail") quit_rail = app.query_one("#action-rail")
assert "continuity" in strip assert "continuity" in strip
assert "monitoring: active in background · persists across reboots" in strip assert "monitoring: active in background · persists across reboots" in strip
assert "r resume — enable monitoring and future boots" in strip assert "r Resume" in str(app.query_one("#action-rail").render())
assert "q quit" not in strip assert "q quit" not in strip
assert rail == "q Quit TUI" assert "q Quit TUI" in rail
assert usage.region.y < service.region.y < quit_rail.region.y assert usage.region.y < service.region.y < quit_rail.region.y
assert usage.region.bottom <= service.region.y assert usage.region.bottom <= service.region.y
assert service.region.bottom <= quit_rail.region.y assert service.region.bottom <= quit_rail.region.y
@@ -405,13 +399,12 @@ class TestDenseScreen:
main_grid = app.query_one("#main-grid") main_grid = app.query_one("#main-grid")
assert str(main_grid.styles.layout) == "<grid>" assert str(main_grid.styles.layout) == "<grid>"
assert main_grid.has_class("paused") assert main_grid.has_class("paused")
assert len(main_grid.styles.grid_rows) == 6
banner = str(paused_banner.render()).lower() banner = str(paused_banner.render()).lower()
assert "monitoring: paused — deliberate disable" in banner assert "monitoring: paused — deliberate disable" in banner
assert "paused time is excluded from your usage habit · resume: fenris monitor resume" in banner assert "paused time is excluded from your usage habit · resume: fenris monitor resume" in banner
assert paused_banner.region.height >= 5 assert paused_banner.region.height >= 2
assert paused_banner.region.y < app.query_one("#usage-history").region.y assert paused_banner.region.y < app.query_one("#activity-panel").region.y
assert app.query_one("#usage-history").region.bottom <= app.query_one( assert app.query_one("#activity-panel").region.bottom <= app.query_one(
"#service-strip" "#service-strip"
).region.y ).region.y
screenshot = app.export_screenshot() screenshot = app.export_screenshot()
@@ -423,11 +416,6 @@ class TestDenseScreen:
assert "monitoring: does not start on next boot" in str( assert "monitoring: does not start on next boot" in str(
app.query_one("#service-strip").render() app.query_one("#service-strip").render()
).lower() ).lower()
dashboard_scroll = app.query_one("#dashboard-scroll")
assert dashboard_scroll.max_scroll_y > 0
dashboard_scroll.focus()
await pilot.press("end")
assert dashboard_scroll.scroll_y == dashboard_scroll.max_scroll_y
footer_text = " ".join( footer_text = " ".join(
"".join( "".join(
ElementTree.fromstring(app.export_screenshot()).itertext() ElementTree.fromstring(app.export_screenshot()).itertext()
@@ -490,23 +478,23 @@ class TestDenseScreen:
async with app.run_test() as pilot: async with app.run_test() as pilot:
headline = str(app.query_one("#headline-band").render()) headline = str(app.query_one("#headline-band").render())
# Identity now shows Fenris by Bongbetic (issue #79) # Identity now shows Fenris by Bongbetic (issue #79)
assert "Fenris by Bongbetic" in headline assert "Fenris by Bongbetic" in str(app.query_one("#identity").render())
assert auth_notice in headline assert auth_notice in str(app.query_one("#auth-notice").render())
# Maker credit removed from service strip (issue #79) # Maker credit removed from service strip (issue #79)
assert "by Bongbetic" not in str(app.query_one("#service-strip").render()) assert "by Bongbetic" not in str(app.query_one("#service-strip").render())
await pilot.pause(0.25) await pilot.pause(0.25)
assert auth_notice not in str(app.query_one("#headline-band").render()) assert not app.query_one("#auth-notice").display
await pilot.pause(0.25) await pilot.pause(0.25)
assert auth_notice not in str(app.query_one("#headline-band").render()) assert not app.query_one("#auth-notice").display
fresh_app = FenrisTuiApp( fresh_app = FenrisTuiApp(
store_path=tmp_path / "nonexistent.db", store_path=tmp_path / "nonexistent.db",
refresh_interval_s=0.2, refresh_interval_s=0.2,
) )
async with fresh_app.run_test(): async with fresh_app.run_test():
assert auth_notice in str(fresh_app.query_one("#headline-band").render()) assert auth_notice in str(fresh_app.query_one("#auth-notice").render())
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -600,7 +588,7 @@ class TestFirstRun:
strip = str(app.query_one("#service-strip").render()) strip = str(app.query_one("#service-strip").render())
assert "no observations yet" in headline.lower() assert "no observations yet" in headline.lower()
assert "r resume — enable monitoring and future boots" in headline.lower() assert "r resume — enable monitoring and future boots" in headline.lower()
assert "r resume — enable monitoring and future boots" in strip.lower() assert "r resume" in str(app.query_one("#action-rail").render()).lower()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -864,15 +852,15 @@ class TestQueryHourlyGraphData:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# DailyBarGraph widget unit tests (issue #75) # HistoryGraph widget unit tests (issue #75)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestDailyBarGraph: class TestHistoryGraph:
def test_empty_data(self, tmp_path): def test_empty_data(self, tmp_path):
"""Empty data shows awaiting message.""" """Empty data shows awaiting message."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
# We test the widget directly via the app's compose # We test the widget directly via the app's compose
graph = DailyBarGraph() graph = HistoryGraph()
# Simulate setting empty data # Simulate setting empty data
from textual.app import App as TextualApp from textual.app import App as TextualApp
class _TestApp(TextualApp): class _TestApp(TextualApp):
@@ -884,14 +872,14 @@ class TestDailyBarGraph:
def test_range_default(self): def test_range_default(self):
"""Default range is 14 days.""" """Default range is 14 days."""
graph = DailyBarGraph() graph = HistoryGraph()
assert graph.range_days == _RANGE_DEFAULT assert graph.range_days == _RANGE_DEFAULT
assert graph.selected_index == -1 assert graph.selected_index == -1
assert graph.view_mode == "daily" assert graph.view_mode == "daily"
def test_trim_to_range(self): def test_trim_to_range(self):
"""Data is trimmed to the selected range.""" """Data is trimmed to the selected range."""
graph = DailyBarGraph() graph = HistoryGraph()
data = [{"day": "2026-09-%02d" % d, "total_bytes": d * 100} data = [{"day": "2026-09-%02d" % d, "total_bytes": d * 100}
for d in range(1, 31)] for d in range(1, 31)]
graph._all_day_data = data graph._all_day_data = data
@@ -902,7 +890,7 @@ class TestDailyBarGraph:
def test_range_all_fits(self): def test_range_all_fits(self):
"""When data fits within range, all days are shown.""" """When data fits within range, all days are shown."""
graph = DailyBarGraph() graph = HistoryGraph()
data = [{"day": "2026-09-%02d" % d, "total_bytes": d * 100} data = [{"day": "2026-09-%02d" % d, "total_bytes": d * 100}
for d in range(1, 8)] for d in range(1, 8)]
graph._all_day_data = data graph._all_day_data = data
@@ -911,7 +899,7 @@ class TestDailyBarGraph:
assert len(graph._day_data) == 7 assert len(graph._day_data) == 7
def test_scale_includes_allocated_and_unallocated_writes(self): def test_scale_includes_allocated_and_unallocated_writes(self):
graph = DailyBarGraph() graph = HistoryGraph()
graph._all_day_data = [{ graph._all_day_data = [{
"day": "2026-09-16", "day": "2026-09-16",
"total_bytes": 800, "total_bytes": 800,
@@ -927,7 +915,7 @@ class TestDailyBarGraph:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestBarGraphTUI: class TestBarGraphTUI:
"""Headless tests for the interactive bar graph.""" """Headless tests for the interactive history plot."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_graph_renders_with_data(self, tmp_path): async def test_graph_renders_with_data(self, tmp_path):
@@ -943,20 +931,21 @@ class TestBarGraphTUI:
app = FenrisTuiApp(store_path=tmp_path / "test.db") app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot: async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
graph = app.query_one("#usage-history") graph = app.query_one("#usage-history")
assert isinstance(graph, DailyBarGraph) assert isinstance(graph, HistoryGraph)
assert len(graph._day_data) > 0 assert len(graph._day_data) > 0
# Legend should be visible # Legend should be visible
legend = str(app.query_one("#bar-legend").render()) legend = str(app.query_one("#bar-legend").render())
assert "Alloc" in legend assert "Unalloc" in legend
assert "Zero" in legend assert "Zero" in legend
range_label = str(app.query_one("#bar-range").render()) range_label = str(app.query_one("#bar-range").render())
assert "14 days" in range_label assert "14 days" in range_label
assert "UTC" in range_label assert "UTC" in range_label
plotted = str(app.query_one("#bar-render").render()) plotted = str(app.query_one("#bar-render").render())
assert "Writes (" in plotted assert "Writes (" in legend
assert "Day UTC" in plotted assert "UTC" in range_label
assert "→" in plotted assert "─" in plotted
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_arrow_selection(self, tmp_path): async def test_arrow_selection(self, tmp_path):
@@ -972,6 +961,7 @@ class TestBarGraphTUI:
app = FenrisTuiApp(store_path=tmp_path / "test.db") app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot: async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
graph = app.query_one("#usage-history") graph = app.query_one("#usage-history")
app.set_focus(graph) app.set_focus(graph)
await pilot.pause() await pilot.pause()
@@ -1002,6 +992,7 @@ class TestBarGraphTUI:
app = FenrisTuiApp(store_path=tmp_path / "test.db") app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot: async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
graph = app.query_one("#usage-history") graph = app.query_one("#usage-history")
app.set_focus(graph) app.set_focus(graph)
await pilot.pause() await pilot.pause()
@@ -1043,6 +1034,7 @@ class TestBarGraphTUI:
app = FenrisTuiApp(store_path=tmp_path / "test.db") app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot: async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
graph = app.query_one("#usage-history") graph = app.query_one("#usage-history")
app.set_focus(graph) app.set_focus(graph)
await pilot.pause() await pilot.pause()
@@ -1079,6 +1071,7 @@ class TestBarGraphTUI:
app = FenrisTuiApp(store_path=tmp_path / "test.db") app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot: async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
graph = app.query_one("#usage-history") graph = app.query_one("#usage-history")
app.set_focus(graph) app.set_focus(graph)
await pilot.pause() await pilot.pause()
@@ -1114,8 +1107,9 @@ class TestBarGraphTUI:
"""Empty store shows awaiting message in graph.""" """Empty store shows awaiting message in graph."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test(size=(80, 24)) as pilot: async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
graph = app.query_one("#usage-history") graph = app.query_one("#usage-history")
assert isinstance(graph, DailyBarGraph) assert isinstance(graph, HistoryGraph)
render = str(app.query_one("#bar-render").render()) render = str(app.query_one("#bar-render").render())
assert "awaiting" in render.lower() assert "awaiting" in render.lower()
@@ -1124,8 +1118,9 @@ class TestBarGraphTUI:
"""Graph pane has a border title.""" """Graph pane has a border title."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test(size=(80, 24)) as pilot: async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
graph = app.query_one("#usage-history") graph = app.query_one("#usage-history")
assert graph.border_title == "Usage history" assert app.query_one("#activity-panel").border_title == "Drive activity"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_glyphs_in_legend(self, tmp_path): async def test_glyphs_in_legend(self, tmp_path):
@@ -1141,13 +1136,11 @@ class TestBarGraphTUI:
app = FenrisTuiApp(store_path=tmp_path / "test.db") app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot: async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
await pilot.pause() await pilot.pause()
legend = str(app.query_one("#bar-legend").render()) legend = str(app.query_one("#bar-legend").render())
assert _GLYPH_ALLOCATED in legend for meaning in ("Writes", "Unalloc", "Gap", "Zero", "Partial"):
assert _GLYPH_UNALLOCATED in legend assert meaning in legend
assert _GLYPH_GAP in legend
assert _GLYPH_ZERO in legend
assert _GLYPH_PARTIAL in legend
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1172,6 +1165,7 @@ class TestConstrainedLayout:
app = FenrisTuiApp(store_path=tmp_path / "test.db") app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot: async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
# All four regions should be visible # All four regions should be visible
assert app.query_one("#headline-band") is not None assert app.query_one("#headline-band") is not None
assert app.query_one("#usage-history") is not None assert app.query_one("#usage-history") is not None
@@ -1180,8 +1174,8 @@ class TestConstrainedLayout:
# Graph should not be in constrained mode # Graph should not be in constrained mode
assert not app._is_constrained_mode assert not app._is_constrained_mode
# Constrained summary should be hidden # Constrained summary should be hidden
summary = app.query_one("#constrained-summary") summary = app.query_one("#bar-render")
assert str(summary.styles.display) == "none" assert "Graph needs" not in str(summary.render())
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_constrained_below_80_width(self, tmp_path): async def test_constrained_below_80_width(self, tmp_path):
@@ -1198,13 +1192,14 @@ class TestConstrainedLayout:
app = FenrisTuiApp(store_path=tmp_path / "test.db") app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot: async with app.run_test(size=(79, 24)) as pilot:
await pilot.press("v", "v")
# Should be in constrained mode # Should be in constrained mode
assert app._is_constrained_mode assert app._is_constrained_mode
# Main grid should have constrained class # Main grid should have constrained class
main_grid = app.query_one("#main-grid") main_grid = app.query_one("#main-grid")
assert main_grid.has_class("constrained") assert main_grid.has_class("constrained")
# Constrained summary should be visible # Constrained summary should be visible
summary = app.query_one("#constrained-summary") summary = app.query_one("#bar-render")
summary_text = str(summary.render()) summary_text = str(summary.render())
assert "Graph needs ≥80×24" in summary_text assert "Graph needs ≥80×24" in summary_text
assert "days" in summary_text assert "days" in summary_text
@@ -1225,10 +1220,11 @@ class TestConstrainedLayout:
app = FenrisTuiApp(store_path=tmp_path / "test.db") app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 23)) as pilot: async with app.run_test(size=(80, 23)) as pilot:
await pilot.press("v", "v")
# Should be in constrained mode # Should be in constrained mode
assert app._is_constrained_mode assert app._is_constrained_mode
# Constrained summary should be visible # Constrained summary should be visible
summary = app.query_one("#constrained-summary") summary = app.query_one("#bar-render")
summary_text = str(summary.render()) summary_text = str(summary.render())
assert "Graph needs ≥80×24" in summary_text assert "Graph needs ≥80×24" in summary_text
@@ -1247,11 +1243,12 @@ class TestConstrainedLayout:
app = FenrisTuiApp(store_path=tmp_path / "test.db") app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot: async with app.run_test(size=(79, 24)) as pilot:
await pilot.press("v", "v")
# Start in constrained mode # Start in constrained mode
assert app._is_constrained_mode assert app._is_constrained_mode
graph = app.query_one("#usage-history") graph = app.query_one("#usage-history")
# Select a day while constrained # Select a day through the visible graph while constrained.
graph.selected_index = 5 await pilot.press(*(["left"] * 8))
# Resize to normal — pilot.resize_terminal changes terminal size # Resize to normal — pilot.resize_terminal changes terminal size
await pilot.resize_terminal(80, 24) await pilot.resize_terminal(80, 24)
@@ -1285,6 +1282,7 @@ class TestConstrainedLayout:
app = FenrisTuiApp(store_path=tmp_path / "test.db") app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot: async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
# Start in normal mode # Start in normal mode
assert not app._is_constrained_mode assert not app._is_constrained_mode
graph = app.query_one("#usage-history") graph = app.query_one("#usage-history")
@@ -1303,7 +1301,7 @@ class TestConstrainedLayout:
# Should be in constrained mode # Should be in constrained mode
assert app._is_constrained_mode assert app._is_constrained_mode
# Constrained summary should show selected day context # Constrained summary should show selected day context
summary = app.query_one("#constrained-summary") summary = app.query_one("#bar-render")
summary_text = str(summary.render()) summary_text = str(summary.render())
assert "Graph needs ≥80×24" in summary_text assert "Graph needs ≥80×24" in summary_text
# Selected day context should survive # Selected day context should survive
@@ -1314,8 +1312,9 @@ class TestConstrainedLayout:
"""Constrained summary shows awaiting message for empty store.""" """Constrained summary shows awaiting message for empty store."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test(size=(79, 24)) as pilot: async with app.run_test(size=(79, 24)) as pilot:
await pilot.press("v", "v")
assert app._is_constrained_mode assert app._is_constrained_mode
summary = app.query_one("#constrained-summary") summary = app.query_one("#bar-render")
summary_text = str(summary.render()) summary_text = str(summary.render())
assert "Graph needs ≥80×24" in summary_text assert "Graph needs ≥80×24" in summary_text
assert "awaiting" in summary_text.lower() assert "awaiting" in summary_text.lower()
@@ -1335,24 +1334,25 @@ class TestConstrainedLayout:
app = FenrisTuiApp(store_path=tmp_path / "test.db") app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot: async with app.run_test(size=(79, 24)) as pilot:
await pilot.press("v", "v")
# All regions should exist # All regions should exist
assert app.query_one("#headline-band") is not None assert app.query_one("#headline-band") is not None
assert app.query_one("#drive-health") is not None assert app.query_one("#drive-health") is not None
assert app.query_one("#service-strip") is not None assert app.query_one("#service-strip") is not None
assert app.query_one("#quit-rail") is not None assert app.query_one("#action-rail") is not None
# Headline should contain projection # Headline should contain projection
headline = str(app.query_one("#headline-band").render()) headline = str(app.query_one("#headline-band").render())
assert "remaining" in headline.lower() or "projection" in headline.lower() assert "remaining" in headline.lower() or "projection" in headline.lower()
# Service strip should have actions # Service strip should have actions
strip = str(app.query_one("#service-strip").render()) strip = str(app.query_one("#action-rail").render())
assert "p Pause" in strip assert "p Pause" in strip
assert "r Resume" in strip assert "r Resume" in strip
assert "q quit" not in strip # Quit is in quit-rail assert "q Quit TUI" in strip
# Quit rail should be visible # Quit rail should be visible
rail = str(app.query_one("#quit-rail").render()) rail = str(app.query_one("#action-rail").render())
assert "Quit" in rail assert "Quit" in rail
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -1368,6 +1368,7 @@ class TestConstrainedLayout:
app = FenrisTuiApp(store_path=tmp_path / "test.db") app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot: async with app.run_test(size=(79, 24)) as pilot:
await pilot.press("v", "v")
strip = str(app.query_one("#service-strip").render()) strip = str(app.query_one("#service-strip").render())
# Status should be visible even with long reasons # Status should be visible even with long reasons
assert "stale" in strip.lower() or "freshness" in strip.lower() assert "stale" in strip.lower() or "freshness" in strip.lower()
@@ -1387,6 +1388,7 @@ class TestConstrainedLayout:
app = FenrisTuiApp(store_path=tmp_path / "test.db") app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot: async with app.run_test(size=(79, 24)) as pilot:
await pilot.press("v", "v")
# Quit should still work # Quit should still work
await pilot.press("q") await pilot.press("q")
assert not app.is_running assert not app.is_running
@@ -1396,6 +1398,7 @@ class TestConstrainedLayout:
"""Theme and motion toggles work in constrained mode.""" """Theme and motion toggles work in constrained mode."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test(size=(79, 24)) as pilot: async with app.run_test(size=(79, 24)) as pilot:
await pilot.press("v", "v")
assert app._is_constrained_mode assert app._is_constrained_mode
# Toggle theme # Toggle theme
await pilot.press("t") await pilot.press("t")
@@ -1415,8 +1418,8 @@ class TestConstrainedLayout:
assert _MIN_HEIGHT == 24 assert _MIN_HEIGHT == 24
def test_is_constrained_with_terminal_width(self, tmp_path): def test_is_constrained_with_terminal_width(self, tmp_path):
"""DailyBarGraph._is_constrained checks terminal width.""" """HistoryGraph._is_constrained checks terminal width."""
graph = DailyBarGraph() graph = HistoryGraph()
# Widget-level check (no terminal width) # Widget-level check (no terminal width)
assert graph._is_constrained() is False assert graph._is_constrained() is False
# Terminal width below minimum # Terminal width below minimum