Modes: a mode that no longer matches the features reads "Custom (based on <mode>)" and offers "Re-apply <mode>"

A mode is a starting point, never a lock and never applied again on its own. modeStatus() (runtime)
compares the flags with the mode's preset: custom, what re-applying turns on and off, and which of
those the preset gained in an update since it was applied. mode_set keeps the preset of that moment
(modes.json "preset", flag names only, bounded; forgotten when the mode changes). Settings, the
first-run screen and the app menu's Mode row show Custom; Re-apply lists what it turns on and off
before it writes anything; a preset that grew is mentioned once on the mode row (Review), never applied.
Tests: exact match, changed by the owner, preset grew, choice made before the preset was kept.
This commit is contained in:
CRTkafa 2026-10-01 03:33:41 +07:00
parent 57524ae2ff
commit 7d30df6aaa
9 changed files with 288 additions and 27 deletions

View file

@ -3,13 +3,18 @@
// preselected; someone who set the app up before modes existed may dismiss it and keep exactly
// what they have. `ModeSettings` is the same choice later, in Settings, with the question whether
// the owner's own changes stay. A mode is a starting point: choosing one writes flag values
// (`features_set`) and remembers the choice (`mode_get/set`); it never takes a switch away.
// (`features_set`) and remembers the choice (`mode_get/set`); it never takes a switch away, and it
// is never applied again on its own. When the flags are no longer what the mode starts from (the
// owner changed features, or an update made the mode larger) the mode reads "Custom (based on
// <mode>)" and one action, "Re-apply <mode>", lists what it turns on and off before it does.
import { useEffect, useState, type ComponentType } from 'react';
import { modeById, normalizeFlags, sparseFlags as sparse, type FeatureTable, type Flags } from '../../../runtime/src/featureTable.ts';
import {
areaFlag, featureWords, modeById, modeFlags, modeStatus, normalizeFlags, sparseFlags as sparse, type FeatureTable, type Flags, type ModeStatus,
} from '../../../runtime/src/featureTable.ts';
import { invoke } from '../frame/bridge.ts';
import { useT } from '../frame/hooks.ts';
import { tx } from '../frame/i18n.ts';
import type { MenuModes } from '../frame/menuModel.ts';
import type { MenuModeStatus, MenuModes } from '../frame/menuModel.ts';
import { shell } from '../frame/state.ts';
import { Badge, Button, Row, Section } from './controls.tsx';
import { modePlan, type PluginFileState } from './featureModel.ts';
@ -17,12 +22,16 @@ import { featureTable, flagsChanged, useFlags } from './PluginFeatures.tsx';
import { panelSite, type Notify } from './site.ts';
import './plugins.css';
/** The onboarding state native code keeps (waybetter-host `modes::ModeState`). */
export type ModeState = { mode: string | null; onboarded: boolean; existing: boolean };
/**
* The onboarding state native code keeps (waybetter-host `modes::ModeState`). `preset`: the mode's
* flags as they were when it was applied (absent: not known).
*/
export type ModeState = { mode: string | null; onboarded: boolean; existing: boolean; preset?: Record<string, boolean> | null };
const getError = (error: unknown) => error instanceof Error ? error.message : String(error);
const listeners = new Set<(state: ModeState) => void>();
let cached: ModeState | null = null;
const share = (next: ModeState) => { cached = next; for (const listener of listeners) listener(next); };
/** The mode state, loaded once and shared by the first-run screen and Settings. Null until loaded (or without modes). */
export function useModeState(): [ModeState | null, (next: ModeState) => void] {
@ -46,12 +55,42 @@ async function applyMode(table: FeatureTable, flags: Flags, from: string | null,
if (!await save(plan.flags)) return null;
for (const file of plan.disable) await invoke('plugin_set_enabled', { file, enabled: false }).catch(() => {});
if (plan.disable.length) window.dispatchEvent(new CustomEvent('bt-plugins-changed'));
const state = await invoke<ModeState>('mode_set', { mode: to, onboarded: true });
const state = await invoke<ModeState>('mode_set', { mode: to, onboarded: true, preset: modeById(table, to)?.flags ?? {} });
flagsChanged();
await panelSite().features?.onMode?.(to);
return state;
}
/** "Re-apply <mode>": every flag back to what the mode starts from now. Plugin files are not touched. */
async function reapplyMode(table: FeatureTable, mode: string, save: (flags: Flags) => Promise<boolean>): Promise<ModeState | null> {
if (!modeById(table, mode) || !await save(modeFlags(table, mode))) return null;
const state = await invoke<ModeState>('mode_set', { mode, onboarded: true, preset: modeById(table, mode)!.flags });
flagsChanged();
return state;
}
/** The "new features" note was looked at: the mode's preset of today is the one remembered, so it is said once. */
const presetSeen = (table: FeatureTable, state: ModeState) =>
invoke<ModeState>('mode_set', { mode: state.mode, onboarded: state.onboarded, preset: modeById(table, state.mode)?.flags ?? {} });
/** A flag as the owner knows it: the feature's label, or the area's name for its switch. */
function flagName(table: FeatureTable, id: string): string {
const area = table.areas.find(entry => areaFlag(entry.id) === id);
if (area) return area.name;
const feature = table.features.find(entry => entry.id === id);
return feature ? tx(featureWords(feature).label, undefined, id) : id;
}
const flagNames = (table: FeatureTable, ids: readonly string[]) => ids.map(id => flagName(table, id)).join(', ');
/** What "Re-apply" would do, in words: one line for what turns on, one for what turns off. */
function ReapplyList({ table, status }: { table: FeatureTable; status: ModeStatus }) {
const t = useT();
return <>
{status.on.length > 0 && <small>{t('wb.modes.turnsOn', { list: flagNames(table, status.on) })}</small>}
{status.off.length > 0 && <small>{t('wb.modes.turnsOff', { list: flagNames(table, status.off) })}</small>}
</>;
}
function ModeCards({ table, selected, onSelect, Art }: { table: FeatureTable; selected: string | null; onSelect(id: string): void; Art?: ComponentType<{ mode: string }> }) {
const t = useT();
return <div className="wbm-cards" role="radiogroup" aria-label={t('wb.modes.section')}>
@ -82,6 +121,8 @@ export function Onboarding({ state, done, notify }: { state: ModeState; done(nex
const [selected, setSelected] = useState<string | null>(state.existing ? null : recommended);
const [busy, setBusy] = useState(false);
const mode = modeById(table, selected);
const stored = modeById(table, state.mode);
const custom = !!stored && !!flags && modeStatus(table, flags, stored.id, state.preset).custom;
async function finish(next: () => Promise<ModeState | null>) {
setBusy(true);
@ -99,6 +140,7 @@ export function Onboarding({ state, done, notify }: { state: ModeState; done(nex
<p>{t('wb.modes.lede')}</p>
<ModeCards table={table} selected={selected} onSelect={setSelected} Art={panel.features?.ModeArt}/>
{state.existing && <p className="wbm-note">{t('wb.modes.existing', { app: panel.brand.name })}</p>}
{custom && <p className="wbm-note">{t('wb.modes.custom', { mode: tx(stored!.name, undefined, stored!.id) })}</p>}
<div className="wbm-actions">
{state.existing && <Button tone={selected ? 'ghost' : 'primary'} disabled={busy} onClick={dismiss}>{t('wb.modes.keepSetup')}</Button>}
{mode && state.existing && <Button disabled={busy || !flags} onClick={() => start(true)}>{t('wb.modes.keep')}</Button>}
@ -118,9 +160,25 @@ function ModeSettingsOf({ table, notify }: { table: FeatureTable; notify: Notify
const [state, setState] = useModeState();
const [flags, , reload] = useFlags(table, notify);
const [asking, setAsking] = useState<{ to: string; changes: number } | null>(null);
const [reviewing, setReviewing] = useState(false);
if (!state || !flags) return null;
const name = (id: string) => tx(modeById(table, id)?.name ?? id, undefined, id);
const save = async (next: Flags) => { await invoke('features_set', { features: sparse(table, next) }); return true; };
const status = modeStatus(table, flags, state.mode, state.preset);
/** Opens the list of what re-applying does. Looking at it is what ends the "new features" note. */
function review() {
setAsking(null);
setReviewing(true);
if (status.added.length) presetSeen(table, state!).then(setState, () => {});
}
async function reapply() {
setReviewing(false);
try {
const next = await reapplyMode(table, state!.mode!, save);
if (next) { setState(next); await reload(); notify(t('wb.modes.applied', { mode: name(next.mode!) })); }
} catch (err) { notify(getError(err), true); }
}
async function apply(to: string, keep: boolean) {
setAsking(null);
@ -138,6 +196,19 @@ function ModeSettingsOf({ table, notify }: { table: FeatureTable; notify: Notify
return <Section title={t('wb.modes.section')} hint={t('wb.modes.sectionHint')} id="modes">
{table.modes.map(mode => {
const current = state.mode === mode.id;
const custom = current && status.custom;
if (custom) return <Row key={mode.id} label={name(mode.id)} hint={tx(mode.description, undefined, '')} keywords={`mode preset custom ${mode.id}`} wide={reviewing}
badge={<><Badge tone="accent">{t('wb.modes.custom', { mode: name(mode.id) })}</Badge>{mode.recommended && <Badge>{t('wb.modes.recommended')}</Badge>}</>}>
{reviewing
? <span className="wbf-ask" role="alertdialog">{t('wb.modes.reapplyHint', { mode: name(mode.id) })} <ReapplyList table={table} status={status}/>
<Button size="sm" tone="primary" onClick={() => void reapply()}>{t('wb.modes.reapply', { mode: name(mode.id) })}</Button>
<Button size="sm" tone="ghost" onClick={() => setReviewing(false)}>{t('wb.common.cancel')}</Button>
</span>
: <span className="wbf-ask">
{status.added.length > 0 && <small>{t('wb.modes.grew', { mode: name(mode.id), count: status.added.length })}</small>}
<Button size="sm" onClick={review}>{status.added.length > 0 ? t('wb.modes.review') : t('wb.modes.reapply', { mode: name(mode.id) })}</Button>
</span>}
</Row>;
return <Row key={mode.id} label={name(mode.id)} hint={tx(mode.description, undefined, '')} keywords={`mode preset ${mode.id}`} wide={asking?.to === mode.id}
badge={<>{current && <Badge tone="accent">{t('wb.modes.current')}</Badge>}{mode.recommended && <Badge>{t('wb.modes.recommended')}</Badge>}</>}>
{asking?.to === mode.id
@ -146,7 +217,7 @@ function ModeSettingsOf({ table, notify }: { table: FeatureTable; notify: Notify
<Button size="sm" onClick={() => void apply(mode.id, false)}>{t('wb.modes.reset', { mode: name(mode.id) })}</Button>
<Button size="sm" tone="ghost" onClick={() => setAsking(null)}>{t('wb.common.cancel')}</Button>
</span>
: <Button size="sm" onClick={() => void choose(mode.id)}>{t('wb.modes.use')}</Button>}
: <Button size="sm" onClick={() => { setReviewing(false); void choose(mode.id); }}>{t('wb.modes.use')}</Button>}
</Row>;
})}
</Section>;
@ -191,7 +262,27 @@ export function menuModes(): MenuModes {
if (!table) return;
const save = async (next: Flags) => { await invoke('features_set', { features: sparse(table, next) }); return true; };
const next = await applyMode(table, await flagsNow(table), (await state()).mode, to, keep, save);
if (next) { cached = next; for (const listener of listeners) listener(next); }
if (next) share(next);
},
async status(): Promise<MenuModeStatus> {
const table = featureTable();
const now = table?.modes.length ? await state() : null;
if (!table || !now) return { custom: false, on: [], off: [], added: 0 };
const status = modeStatus(table, await flagsNow(table), now.mode, now.preset);
return { custom: status.custom, on: status.on.map(id => flagName(table, id)), off: status.off.map(id => flagName(table, id)), added: status.added.length };
},
async reapply() {
const table = featureTable();
const mode = (await state()).mode;
if (!table || !mode) return;
const save = async (next: Flags) => { await invoke('features_set', { features: sparse(table, next) }); return true; };
const next = await reapplyMode(table, mode, save);
if (next) share(next);
},
async seen() {
const table = featureTable();
const now = await state();
if (table && now.mode) share(await presetSeen(table, now));
},
};
}