docs/internal/settings-modified-indicator-design.md
The current settings UI has several UX issues related to the "modified" indicator:
Incorrect baseline for comparison: The current implementation compares settings values against the schema default. This causes:
{}Section-level indicators are misleading: The dot indicators on sections (General, Editor, Plugins) show "modified" based on comparison to schema defaults, not based on what the user has actually configured in the current layer.
No visibility into individual item modifications: Users cannot see which specific items have been modified at the current layer vs inherited from a lower layer.
Hidden inheritance sources: Users have no transparency into where a setting's effective value originates, making it difficult to understand configuration state.
Design a UX similar to IntelliJ IDEA's and VS Code's settings:
Fresh uses a 4-layer configuration system (highest precedence first):
.fresh/config.json)~/.config/fresh/config.json)Values cascade: higher layers override lower layers. The final config is the merge of all layers.
The effective value V_eff of a setting s is determined by evaluating layers in precedence order:
V_eff(s) = V(s, layer_k) where k = max{i | V(s, layer_i) is defined}
In other words: the effective value comes from the highest-precedence layer that defines it.
Research indicates that systems hiding the source of a setting's value cause the highest degree of user frustration. The UI must clearly show:
This follows the "Recognizability over Recall" principle - users should see inheritance state at a glance rather than having to remember or investigate.
Current behavior: modified = (current_value != schema_default)
Proposed behavior: modified = (value is defined in target_layer)
For example, when editing User layer settings:
This aligns with the UX concept: "modified" means "the user explicitly configured this in the current layer."
The dot indicator next to category names (e.g., "General", "Editor") should show:
This is computed by aggregating: category_modified = any(item.modified for item in category.items)
Each setting item should display:
Layer source badge: A small label showing which layer the current value comes from
[default] - dimmed/gray (System layer)[user] - subtle highlight (User layer)[project] - distinct color (Project layer)[session] - ephemeral indicator (Session layer)Modified indicator (●): Shows if the item is defined in the current target layer
Following the research on visual hierarchy, the modified indicator should have higher visual weight than the layer source badge, as it represents actionable state (something the user can reset).
┌─────────────────────────────────────────────────────────────┐
│ ● Tab Size : [ 4 ] [-] [+] [project] │
│ Number of spaces per tab │
├─────────────────────────────────────────────────────────────┤
│ Line Numbers : [x] [default] │
│ Show line numbers in the gutter │
└─────────────────────────────────────────────────────────────┘
In this example:
Current behavior: Reset sets the value to schema default.
Proposed behavior: Reset removes the value from the current layer's delta.
This means:
tab_size: 2, clicking Reset removes it from User layerFor destructive operations (reset all in section, reset to defaults), show a confirmation indicating:
x-no-add)Plugins and other auto-discovered content use x-no-add schema extension:
Following the 80/20 principle from UX research:
Currently Fresh shows all settings flat, which may be acceptable given the search functionality.
build_item / build_page functionsAdd parameters:
layer_sources: &HashMap<String, ConfigLayer> - Maps paths to their source layertarget_layer: ConfigLayer - The layer being editedCalculate modified as:
// For regular items
let modified = layer_sources.get(&schema.path) == Some(&target_layer);
// For Maps with no_add (auto-managed)
let modified = false; // Container is never "modified"
// For entries WITHIN Maps (even no_add maps)
// Check if the specific entry path exists in target layer
let entry_path = format!("{}/{}", schema.path, entry_key);
let entry_modified = layer_sources.get(&entry_path) == Some(&target_layer);
reset_current_to_default functionChange from:
// Set value to schema default
self.set_pending_change(&path, default.clone());
To:
// Remove value from delta (fall back to inherited)
// Only if the item is defined in the current layer
if item.modified {
self.remove_from_delta(&path);
// Recalculate effective value from remaining layers
let new_value = self.compute_effective_value(&path);
self.update_control(&path, new_value);
}
remove_from_delta methodNew method to remove a path from the pending changes that will result in deletion from the layer:
/// Mark a path for removal from the current layer's delta.
/// On save, this path will be deleted from the layer file.
pub fn remove_from_delta(&mut self, path: &str) {
// Use a special marker value or separate tracking for deletions
self.pending_deletions.insert(path.to_string());
self.pending_changes.remove(path);
}
Already correct: page.items.iter().any(|i| i.modified)
Once modified is calculated correctly per-item, section indicators will automatically work.
In render.rs, add rendering for layer source badges:
fn render_layer_badge(layer: ConfigLayer, theme: &Theme) -> Span {
let (text, style) = match layer {
ConfigLayer::System => ("default", Style::default().fg(theme.text_muted)),
ConfigLayer::User => ("user", Style::default().fg(theme.text_secondary)),
ConfigLayer::Project => ("project", Style::default().fg(theme.accent)),
ConfigLayer::Session => ("session", Style::default().fg(theme.warning)),
};
Span::styled(format!("[{}]", text), style)
}
build_item signature to accept layer infobuild_page, build_pages)layer_sources and target_layer from SettingsStatepending_deletions tracking to SettingsStatereset_current_to_default to remove from deltano_add maps don't show modifiedThis design follows key principles identified in configuration UX research: