Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,9 @@ export type SettingsPreferencesCopy = {
displayName: string;
displayNameHelp: string;
displayNamePlaceholder: string;
displayNameUnset: string;
displayNameChange: string;
displayNameSet: string;
interfaceLanguage: string;
interfaceLanguageHelp: string;
localeOptions: ReadonlyArray<readonly [UiLocalePreference, string]>;
Expand DownExpand Up@@ -128,7 +131,7 @@ export type SettingsPreferencesCopy = {
const SETTINGS_PREFERENCES_COPY_BY_LOCALE = {
zh: {
personalization: {
saveFailed: '保存失败', displayName: '显示名称', displayNameHelp: 'Maka 在聊天里会以这个名字称呼你。留空就用默认的“你”。', displayNamePlaceholder: '例如:JK',
saveFailed: '保存失败', displayName: '显示名称', displayNameHelp: 'Maka 在聊天里会以这个名字称呼你。留空就用默认的“你”。', displayNamePlaceholder: '例如:JK', displayNameUnset: '未设置,Maka 会称呼你“你”', displayNameChange: '更改', displayNameSet: '设置',
interfaceLanguage: '界面语言', interfaceLanguageHelp: '选择 Maka 界面的显示语言。切换后立即生效,重启后保持。', localeOptions: [['auto', '跟随系统'], ['zh', '中文'], ['en', 'English']],
assistantTone: '助手语气偏好', assistantToneHelp: '最多 500 字,只影响回答的语气和风格。权限确认与安全规则不受影响;改动会自动保存。', assistantTonePlaceholder: '例如:技术严谨、偏简洁、不要 emoji。',
},
Expand DownExpand Up@@ -159,7 +162,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = {
},
en: {
personalization: {
saveFailed: 'Could not save', displayName: 'Display name', displayNameHelp: 'Maka uses this name when addressing you. Leave it blank to use “you”.', displayNamePlaceholder: 'For example: JK', interfaceLanguage: 'Interface language', interfaceLanguageHelp: 'Choose the language used by Maka. Changes apply immediately and persist after restart.', localeOptions: [['auto', 'Follow system'], ['zh', '中文'], ['en', 'English']], assistantTone: 'Assistant tone', assistantToneHelp: 'Up to 500 characters. This changes response style only; permission and safety rules still apply. Changes save automatically.', assistantTonePlaceholder: 'For example: technically rigorous, concise, and no emoji.',
saveFailed: 'Could not save', displayName: 'Display name', displayNameHelp: 'Maka uses this name when addressing you. Leave it blank to use “you”.', displayNamePlaceholder: 'For example: JK', displayNameUnset: 'Not set — Maka will say “you”', displayNameChange: 'Change', displayNameSet: 'Set', interfaceLanguage: 'Interface language', interfaceLanguageHelp: 'Choose the language used by Maka. Changes apply immediately and persist after restart.', localeOptions: [['auto', 'Follow system'], ['zh', '中文'], ['en', 'English']], assistantTone: 'Assistant tone', assistantToneHelp: 'Up to 500 characters. This changes response style only; permission and safety rules still apply. Changes save automatically.', assistantTonePlaceholder: 'For example: technically rigorous, concise, and no emoji.',
},
sections: {
identity: 'Identity', identityHelp: 'How Maka addresses you, plus interface language and response tone.',
Expand Down
56 changes: 46 additions & 10 deletions apps/desktop/src/renderer/settings/appearance-settings-page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ import {
VStack,
} from '@astryxdesign/core';
import { SettingsField, SettingsPage, SettingsRow, SettingsSection } from './settings-section';
import { SettingsExpandableRow } from './settings-expandable-row';
import { getSettingsSharedCopy } from '../locales/settings-shared-copy';
import type {
AppSettings,
PersonalizationSettings,
Expand DownExpand Up@@ -69,6 +71,9 @@ export function PersonalizationSettingsPage(props: {
const locale = useUiLocale();
const copy = getSettingsPreferencesCopy(locale).personalization;
const sections = getSettingsPreferencesCopy(locale).sections;
const sharedCopy = getSettingsSharedCopy(locale);
// At most one row in the group is open — the template's own rule.
const [expandedRow, setExpandedRow] = useState<string | null>(null);
// Persist the tone textarea this long after the user stops typing; blur
// flushes immediately regardless.
const TONE_AUTOSAVE_DEBOUNCE_MS = 800;
Expand DownExpand Up@@ -120,33 +125,36 @@ export function PersonalizationSettingsPage(props: {

// Shared persist path for every personalization field. Locale has its own
// last-write-wins lane so unrelated saves cannot steal rollback ownership.
async function persistPersonalization(patch: Partial<PersonalizationSettings>) {
// Returns whether the write landed. The autosaving fields ignore it — they
// have nowhere to put the answer — but a row that closes on save has to
// know, or a failed write collapses the row onto the old value and drops
// the draft with only a toast to show for it.
async function persistPersonalization(patch: Partial<PersonalizationSettings>): Promise<boolean> {
const ticket = ++persistTicketRef.current;
const localeTicket = patch.uiLocale === undefined ? null : ++localePersistTicketRef.current;
persistPendingCountRef.current += 1;
try {
const result = await props.onUpdate({ personalization: patch });
if (!personalizationMountedRef.current) return;
// Saved. An unmounted page just has nowhere left to reflect it.
if (!personalizationMountedRef.current) return true;
if (localeTicket !== null && localeTicket === localePersistTicketRef.current) {
setUiLocale(result.settings.personalization.uiLocale);
}
return true;
} catch (error) {
if (!personalizationMountedRef.current) return;
if (!personalizationMountedRef.current) return false;
if (localeTicket !== null && localeTicket === localePersistTicketRef.current) {
setUiLocale(value.uiLocale);
}
if (ticket === persistTicketRef.current) {
toast.error(copy.saveFailed, settingsActionErrorMessage(error, locale));
}
return false;
} finally {
persistPendingCountRef.current = Math.max(0, persistPendingCountRef.current - 1);
}
}

function flushDisplayName(nextValue: string) {
void persistPersonalization({ displayName: nextValue.trim().slice(0, 60) });
}

function persistLocale(next: UiLocalePreference) {
setUiLocale(next);
void persistPersonalization({ uiLocale: next });
Expand DownExpand Up@@ -177,18 +185,46 @@ export function PersonalizationSettingsPage(props: {
neighboring preferences; the full-width tone field uses the vertical
row variant. */}
<SettingsSection title={sections.identity} description={sections.identityHelp}>
<SettingsField>
{/* A name you set once and then read. A permanently-open input asked
the user to fill in something already filled in, and its blur-save
gave them no way to back out of a change. The row reports the
settled value and opens on demand; Cancel puts the draft back. */}
<SettingsExpandableRow
label={copy.displayName}
value={value.displayName || copy.displayNameUnset}
actionLabel={value.displayName ? copy.displayNameChange : copy.displayNameSet}
isEditing={expandedRow === 'displayName'}
canSave={displayName.trim() !== value.displayName}
saveLabel={sharedCopy.save}
cancelLabel={sharedCopy.cancel}
onEdit={() => {
setDisplayName(value.displayName);
setExpandedRow('displayName');
}}
onCancel={() => {
setDisplayName(value.displayName);
setExpandedRow(null);
}}
onSave={async () => {
// Only close on a write that landed: a failed save leaves the row
// open with the draft intact, which is the promise explicit saving
// makes and blur-autosave could not keep.
if (await persistPersonalization({ displayName: displayName.trim().slice(0, 60) })) {
setExpandedRow(null);
}
}}
>
<TextInput
type="text"
value={displayName}
onChange={(value) => setDisplayName(value.slice(0, 60))}
onBlur={() => flushDisplayName(displayName)}
placeholder={copy.displayNamePlaceholder}
label={copy.displayName}
description={copy.displayNameHelp}
isLabelHidden
width="100%"
/>
</SettingsField>
</SettingsExpandableRow>

{/*
PR-LANG-PREF-0 (WAWQAQ msg `edc9cb41` + kenji `7e532892`
Expand Down
129 changes: 129 additions & 0 deletions apps/desktop/src/renderer/settings/settings-expandable-row.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
import { useEffect, useRef, type ReactNode } from 'react';
import { Button, HStack, Text } from '@astryxdesign/core';
import { SettingsField, SettingsRow } from './settings-section';

/**
* A settled value that becomes a form only when the user asks it to.
*
* The shape is Astryx's own — the `settings-sidebar` template's ExpandableRow
* (`@astryxdesign/cli/templates/pages/settings-sidebar/page.tsx:140-193`): a
* row reports its label and current value with one affordance to change it,
* and swaps in place for the editor plus Save / Cancel. At most one row in a
* group is open at a time, which is the caller's `expandedRow` state.
*
* The shape is copied; the template's implementation is not, because it has
* three problems this component exists to not have:
*
* 1. The template's trigger is `<a href="#">` with `preventDefault`. That
* announces itself as a link, ignores Space, and points nowhere. Opening a
* form is a button.
*
* 2. The template has no focus management at all. Expanding moves focus into
* the editor here, and Save / Cancel return it to the trigger the user came
* from — otherwise collapsing drops focus to the top of the document and a
* keyboard user loses their place in the list.
*
* 3. The template's Cancel only closes; its fields edit live, so there is
* nothing to discard. Ours is a real discard: the caller reverts its draft
* in `onCancel`, which is what makes Cancel mean anything.
*
* Built on `SettingsRow` rather than a bare HStack so the collapsed row keeps
* the Item vocabulary the rest of the surface uses — the `settingsRowEnd`
* width ceiling and the container queries in rows.css apply here too. The
* expanded editor sits in a `SettingsField`, the same full-width block a
* permanently-open input would use.
*/
export function SettingsExpandableRow(props: {
label: string;
/** The settled value, shown while collapsed. */
value: ReactNode;
/** Label for the affordance that opens the editor (更改 / 设置 / 编辑). */
actionLabel: string;
isEditing: boolean;
isDisabled?: boolean;
/** Save stays disabled until the draft actually differs from the value. */
canSave?: boolean;
saveLabel: string;
cancelLabel: string;
onEdit(): void;
onCancel(): void;
onSave(): void | Promise<void>;
children: ReactNode;
}) {
const triggerRef = useRef<HTMLButtonElement>(null);
const editorRef = useRef<HTMLDivElement>(null);
// Only pull focus for a transition the user drove. Without this the row
// would grab focus on first paint whenever a caller mounts already-expanded.
const wasEditingRef = useRef(props.isEditing);

useEffect(() => {
const opened = props.isEditing && !wasEditingRef.current;
const closed = !props.isEditing && wasEditingRef.current;
wasEditingRef.current = props.isEditing;
if (opened) {
// The first control the editor renders, whatever the caller put there.
const first = editorRef.current?.querySelector<HTMLElement>(
'input, textarea, select, button, [contenteditable="true"], [tabindex]:not([tabindex="-1"])',
);
first?.focus();
return;
}
if (closed) triggerRef.current?.focus();
}, [props.isEditing]);

// The collapsed row carries no aria-expanded / aria-controls on purpose.
// Those describe a disclosure — a trigger that stays put while a region
// beside it opens — and this is a mode swap: the trigger unmounts when the
// editor replaces it, so aria-expanded could never reach true and
// aria-controls would name a node that does not exist while collapsed.
// Declaring a contract the DOM never honours is worse than declaring none;
// the focus move is what tells assistive tech the mode changed, and that
// part is measured rather than assumed.
if (!props.isEditing) {
return (
<SettingsRow
label={props.label}
description={props.value}
align="start"
end={(
<Button
ref={triggerRef}
variant="ghost"
size="sm"
isDisabled={props.isDisabled}
onClick={props.onEdit}
label={props.actionLabel}
/>
)}
/>
);
}

return (
<SettingsField>
<div ref={editorRef} className="settingsExpandableEditor">
{/* The label survives the swap. Collapsed, the row's own label names
the value; expanded, the editor would otherwise be an unlabelled
box — the user pressed 更改 on something and must still be able to
see what. The caller hides the control's own label instead, so the
name is stated once. */}
<Text type="body" weight="semibold">{props.label}</Text>
{props.children}
<HStack gap={2}>
<Button
variant="primary"
isDisabled={props.isDisabled || props.canSave === false}
clickAction={() => props.onSave()}
label={props.saveLabel}
/>
<Button
variant="ghost"
isDisabled={props.isDisabled}
onClick={props.onCancel}
label={props.cancelLabel}
/>
</HStack>
</div>
</SettingsField>
);
}
9 changes: 9 additions & 0 deletions apps/desktop/src/renderer/styles/settings/rows.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -141,3 +141,12 @@
min-height: 2rem;
}
}

/* The expanded half of SettingsExpandableRow: the editor the caller supplies,
then its Save / Cancel pair. A grid rather than margins so the gap is the
row group's own rhythm and the editor keeps the field block's full width. */
.settingsExpandableEditor {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--space-3);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,9 @@ export type SettingsPreferencesCopy = {
displayName: string;
displayNameHelp: string;
displayNamePlaceholder: string;
displayNameUnset: string;
displayNameChange: string;
displayNameSet: string;
interfaceLanguage: string;
interfaceLanguageHelp: string;
localeOptions: ReadonlyArray<readonly [UiLocalePreference, string]>;
Expand DownExpand Up@@ -128,7 +131,7 @@ export type SettingsPreferencesCopy = {
const SETTINGS_PREFERENCES_COPY_BY_LOCALE = {
zh: {
personalization: {
saveFailed: '保存失败', displayName: '显示名称', displayNameHelp: 'Maka 在聊天里会以这个名字称呼你。留空就用默认的“你”。', displayNamePlaceholder: '例如:JK',
saveFailed: '保存失败', displayName: '显示名称', displayNameHelp: 'Maka 在聊天里会以这个名字称呼你。留空就用默认的“你”。', displayNamePlaceholder: '例如:JK', displayNameUnset: '未设置,Maka 会称呼你“你”', displayNameChange: '更改', displayNameSet: '设置',
interfaceLanguage: '界面语言', interfaceLanguageHelp: '选择 Maka 界面的显示语言。切换后立即生效,重启后保持。', localeOptions: [['auto', '跟随系统'], ['zh', '中文'], ['en', 'English']],
assistantTone: '助手语气偏好', assistantToneHelp: '最多 500 字,只影响回答的语气和风格。权限确认与安全规则不受影响;改动会自动保存。', assistantTonePlaceholder: '例如:技术严谨、偏简洁、不要 emoji。',
},
Expand DownExpand Up@@ -159,7 +162,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = {
},
en: {
personalization: {
saveFailed: 'Could not save', displayName: 'Display name', displayNameHelp: 'Maka uses this name when addressing you. Leave it blank to use “you”.', displayNamePlaceholder: 'For example: JK', interfaceLanguage: 'Interface language', interfaceLanguageHelp: 'Choose the language used by Maka. Changes apply immediately and persist after restart.', localeOptions: [['auto', 'Follow system'], ['zh', '中文'], ['en', 'English']], assistantTone: 'Assistant tone', assistantToneHelp: 'Up to 500 characters. This changes response style only; permission and safety rules still apply. Changes save automatically.', assistantTonePlaceholder: 'For example: technically rigorous, concise, and no emoji.',
saveFailed: 'Could not save', displayName: 'Display name', displayNameHelp: 'Maka uses this name when addressing you. Leave it blank to use “you”.', displayNamePlaceholder: 'For example: JK', displayNameUnset: 'Not set — Maka will say “you”', displayNameChange: 'Change', displayNameSet: 'Set', interfaceLanguage: 'Interface language', interfaceLanguageHelp: 'Choose the language used by Maka. Changes apply immediately and persist after restart.', localeOptions: [['auto', 'Follow system'], ['zh', '中文'], ['en', 'English']], assistantTone: 'Assistant tone', assistantToneHelp: 'Up to 500 characters. This changes response style only; permission and safety rules still apply. Changes save automatically.', assistantTonePlaceholder: 'For example: technically rigorous, concise, and no emoji.',
},
sections: {
identity: 'Identity', identityHelp: 'How Maka addresses you, plus interface language and response tone.',
Expand Down
56 changes: 46 additions & 10 deletions apps/desktop/src/renderer/settings/appearance-settings-page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ import {
VStack,
} from '@astryxdesign/core';
import { SettingsField, SettingsPage, SettingsRow, SettingsSection } from './settings-section';
import { SettingsExpandableRow } from './settings-expandable-row';
import { getSettingsSharedCopy } from '../locales/settings-shared-copy';
import type {
AppSettings,
PersonalizationSettings,
Expand DownExpand Up@@ -69,6 +71,9 @@ export function PersonalizationSettingsPage(props: {
const locale = useUiLocale();
const copy = getSettingsPreferencesCopy(locale).personalization;
const sections = getSettingsPreferencesCopy(locale).sections;
const sharedCopy = getSettingsSharedCopy(locale);
// At most one row in the group is open — the template's own rule.
const [expandedRow, setExpandedRow] = useState<string | null>(null);
// Persist the tone textarea this long after the user stops typing; blur
// flushes immediately regardless.
const TONE_AUTOSAVE_DEBOUNCE_MS = 800;
Expand DownExpand Up@@ -120,33 +125,36 @@ export function PersonalizationSettingsPage(props: {

// Shared persist path for every personalization field. Locale has its own
// last-write-wins lane so unrelated saves cannot steal rollback ownership.
async function persistPersonalization(patch: Partial<PersonalizationSettings>) {
// Returns whether the write landed. The autosaving fields ignore it — they
// have nowhere to put the answer — but a row that closes on save has to
// know, or a failed write collapses the row onto the old value and drops
// the draft with only a toast to show for it.
async function persistPersonalization(patch: Partial<PersonalizationSettings>): Promise<boolean> {
const ticket = ++persistTicketRef.current;
const localeTicket = patch.uiLocale === undefined ? null : ++localePersistTicketRef.current;
persistPendingCountRef.current += 1;
try {
const result = await props.onUpdate({ personalization: patch });
if (!personalizationMountedRef.current) return;
// Saved. An unmounted page just has nowhere left to reflect it.
if (!personalizationMountedRef.current) return true;
if (localeTicket !== null && localeTicket === localePersistTicketRef.current) {
setUiLocale(result.settings.personalization.uiLocale);
}
return true;
} catch (error) {
if (!personalizationMountedRef.current) return;
if (!personalizationMountedRef.current) return false;
if (localeTicket !== null && localeTicket === localePersistTicketRef.current) {
setUiLocale(value.uiLocale);
}
if (ticket === persistTicketRef.current) {
toast.error(copy.saveFailed, settingsActionErrorMessage(error, locale));
}
return false;
} finally {
persistPendingCountRef.current = Math.max(0, persistPendingCountRef.current - 1);
}
}

function flushDisplayName(nextValue: string) {
void persistPersonalization({ displayName: nextValue.trim().slice(0, 60) });
}

function persistLocale(next: UiLocalePreference) {
setUiLocale(next);
void persistPersonalization({ uiLocale: next });
Expand DownExpand Up@@ -177,18 +185,46 @@ export function PersonalizationSettingsPage(props: {
neighboring preferences; the full-width tone field uses the vertical
row variant. */}
<SettingsSection title={sections.identity} description={sections.identityHelp}>
<SettingsField>
{/* A name you set once and then read. A permanently-open input asked
the user to fill in something already filled in, and its blur-save
gave them no way to back out of a change. The row reports the
settled value and opens on demand; Cancel puts the draft back. */}
<SettingsExpandableRow
label={copy.displayName}
value={value.displayName || copy.displayNameUnset}
actionLabel={value.displayName ? copy.displayNameChange : copy.displayNameSet}
isEditing={expandedRow === 'displayName'}
canSave={displayName.trim() !== value.displayName}
saveLabel={sharedCopy.save}
cancelLabel={sharedCopy.cancel}
onEdit={() => {
setDisplayName(value.displayName);
setExpandedRow('displayName');
}}
onCancel={() => {
setDisplayName(value.displayName);
setExpandedRow(null);
}}
onSave={async () => {
// Only close on a write that landed: a failed save leaves the row
// open with the draft intact, which is the promise explicit saving
// makes and blur-autosave could not keep.
if (await persistPersonalization({ displayName: displayName.trim().slice(0, 60) })) {
setExpandedRow(null);
}
}}
>
<TextInput
type="text"
value={displayName}
onChange={(value) => setDisplayName(value.slice(0, 60))}
onBlur={() => flushDisplayName(displayName)}
placeholder={copy.displayNamePlaceholder}
label={copy.displayName}
description={copy.displayNameHelp}
isLabelHidden
width="100%"
/>
</SettingsField>
</SettingsExpandableRow>

{/*
PR-LANG-PREF-0 (WAWQAQ msg `edc9cb41` + kenji `7e532892`
Expand Down
129 changes: 129 additions & 0 deletions apps/desktop/src/renderer/settings/settings-expandable-row.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
import { useEffect, useRef, type ReactNode } from 'react';
import { Button, HStack, Text } from '@astryxdesign/core';
import { SettingsField, SettingsRow } from './settings-section';

/**
* A settled value that becomes a form only when the user asks it to.
*
* The shape is Astryx's own — the `settings-sidebar` template's ExpandableRow
* (`@astryxdesign/cli/templates/pages/settings-sidebar/page.tsx:140-193`): a
* row reports its label and current value with one affordance to change it,
* and swaps in place for the editor plus Save / Cancel. At most one row in a
* group is open at a time, which is the caller's `expandedRow` state.
*
* The shape is copied; the template's implementation is not, because it has
* three problems this component exists to not have:
*
* 1. The template's trigger is `<a href="#">` with `preventDefault`. That
* announces itself as a link, ignores Space, and points nowhere. Opening a
* form is a button.
*
* 2. The template has no focus management at all. Expanding moves focus into
* the editor here, and Save / Cancel return it to the trigger the user came
* from — otherwise collapsing drops focus to the top of the document and a
* keyboard user loses their place in the list.
*
* 3. The template's Cancel only closes; its fields edit live, so there is
* nothing to discard. Ours is a real discard: the caller reverts its draft
* in `onCancel`, which is what makes Cancel mean anything.
*
* Built on `SettingsRow` rather than a bare HStack so the collapsed row keeps
* the Item vocabulary the rest of the surface uses — the `settingsRowEnd`
* width ceiling and the container queries in rows.css apply here too. The
* expanded editor sits in a `SettingsField`, the same full-width block a
* permanently-open input would use.
*/
export function SettingsExpandableRow(props: {
label: string;
/** The settled value, shown while collapsed. */
value: ReactNode;
/** Label for the affordance that opens the editor (更改 / 设置 / 编辑). */
actionLabel: string;
isEditing: boolean;
isDisabled?: boolean;
/** Save stays disabled until the draft actually differs from the value. */
canSave?: boolean;
saveLabel: string;
cancelLabel: string;
onEdit(): void;
onCancel(): void;
onSave(): void | Promise<void>;
children: ReactNode;
}) {
const triggerRef = useRef<HTMLButtonElement>(null);
const editorRef = useRef<HTMLDivElement>(null);
// Only pull focus for a transition the user drove. Without this the row
// would grab focus on first paint whenever a caller mounts already-expanded.
const wasEditingRef = useRef(props.isEditing);

useEffect(() => {
const opened = props.isEditing && !wasEditingRef.current;
const closed = !props.isEditing && wasEditingRef.current;
wasEditingRef.current = props.isEditing;
if (opened) {
// The first control the editor renders, whatever the caller put there.
const first = editorRef.current?.querySelector<HTMLElement>(
'input, textarea, select, button, [contenteditable="true"], [tabindex]:not([tabindex="-1"])',
);
first?.focus();
return;
}
if (closed) triggerRef.current?.focus();
}, [props.isEditing]);

// The collapsed row carries no aria-expanded / aria-controls on purpose.
// Those describe a disclosure — a trigger that stays put while a region
// beside it opens — and this is a mode swap: the trigger unmounts when the
// editor replaces it, so aria-expanded could never reach true and
// aria-controls would name a node that does not exist while collapsed.
// Declaring a contract the DOM never honours is worse than declaring none;
// the focus move is what tells assistive tech the mode changed, and that
// part is measured rather than assumed.
if (!props.isEditing) {
return (
<SettingsRow
label={props.label}
description={props.value}
align="start"
end={(
<Button
ref={triggerRef}
variant="ghost"
size="sm"
isDisabled={props.isDisabled}
onClick={props.onEdit}
label={props.actionLabel}
/>
)}
/>
);
}

return (
<SettingsField>
<div ref={editorRef} className="settingsExpandableEditor">
{/* The label survives the swap. Collapsed, the row's own label names
the value; expanded, the editor would otherwise be an unlabelled
box — the user pressed 更改 on something and must still be able to
see what. The caller hides the control's own label instead, so the
name is stated once. */}
<Text type="body" weight="semibold">{props.label}</Text>
{props.children}
<HStack gap={2}>
<Button
variant="primary"
isDisabled={props.isDisabled || props.canSave === false}
clickAction={() => props.onSave()}
label={props.saveLabel}
/>
<Button
variant="ghost"
isDisabled={props.isDisabled}
onClick={props.onCancel}
label={props.cancelLabel}
/>
</HStack>
</div>
</SettingsField>
);
}
9 changes: 9 additions & 0 deletions apps/desktop/src/renderer/styles/settings/rows.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -141,3 +141,12 @@
min-height: 2rem;
}
}

/* The expanded half of SettingsExpandableRow: the editor the caller supplies,
then its Save / Cancel pair. A grid rather than margins so the gap is the
row group's own rhythm and the editor keeps the field block's full width. */
.settingsExpandableEditor {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--space-3);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,9 @@ export type SettingsPreferencesCopy = {
displayName: string;
displayNameHelp: string;
displayNamePlaceholder: string;
displayNameUnset: string;
displayNameChange: string;
displayNameSet: string;
interfaceLanguage: string;
interfaceLanguageHelp: string;
localeOptions: ReadonlyArray<readonly [UiLocalePreference, string]>;
Expand DownExpand Up@@ -128,7 +131,7 @@ export type SettingsPreferencesCopy = {
const SETTINGS_PREFERENCES_COPY_BY_LOCALE = {
zh: {
personalization: {
saveFailed: '保存失败', displayName: '显示名称', displayNameHelp: 'Maka 在聊天里会以这个名字称呼你。留空就用默认的“你”。', displayNamePlaceholder: '例如:JK',
saveFailed: '保存失败', displayName: '显示名称', displayNameHelp: 'Maka 在聊天里会以这个名字称呼你。留空就用默认的“你”。', displayNamePlaceholder: '例如:JK', displayNameUnset: '未设置,Maka 会称呼你“你”', displayNameChange: '更改', displayNameSet: '设置',
interfaceLanguage: '界面语言', interfaceLanguageHelp: '选择 Maka 界面的显示语言。切换后立即生效,重启后保持。', localeOptions: [['auto', '跟随系统'], ['zh', '中文'], ['en', 'English']],
assistantTone: '助手语气偏好', assistantToneHelp: '最多 500 字,只影响回答的语气和风格。权限确认与安全规则不受影响;改动会自动保存。', assistantTonePlaceholder: '例如:技术严谨、偏简洁、不要 emoji。',
},
Expand DownExpand Up@@ -159,7 +162,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = {
},
en: {
personalization: {
saveFailed: 'Could not save', displayName: 'Display name', displayNameHelp: 'Maka uses this name when addressing you. Leave it blank to use “you”.', displayNamePlaceholder: 'For example: JK', interfaceLanguage: 'Interface language', interfaceLanguageHelp: 'Choose the language used by Maka. Changes apply immediately and persist after restart.', localeOptions: [['auto', 'Follow system'], ['zh', '中文'], ['en', 'English']], assistantTone: 'Assistant tone', assistantToneHelp: 'Up to 500 characters. This changes response style only; permission and safety rules still apply. Changes save automatically.', assistantTonePlaceholder: 'For example: technically rigorous, concise, and no emoji.',
saveFailed: 'Could not save', displayName: 'Display name', displayNameHelp: 'Maka uses this name when addressing you. Leave it blank to use “you”.', displayNamePlaceholder: 'For example: JK', displayNameUnset: 'Not set — Maka will say “you”', displayNameChange: 'Change', displayNameSet: 'Set', interfaceLanguage: 'Interface language', interfaceLanguageHelp: 'Choose the language used by Maka. Changes apply immediately and persist after restart.', localeOptions: [['auto', 'Follow system'], ['zh', '中文'], ['en', 'English']], assistantTone: 'Assistant tone', assistantToneHelp: 'Up to 500 characters. This changes response style only; permission and safety rules still apply. Changes save automatically.', assistantTonePlaceholder: 'For example: technically rigorous, concise, and no emoji.',
},
sections: {
identity: 'Identity', identityHelp: 'How Maka addresses you, plus interface language and response tone.',
Expand Down
56 changes: 46 additions & 10 deletions apps/desktop/src/renderer/settings/appearance-settings-page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ import {
VStack,
} from '@astryxdesign/core';
import { SettingsField, SettingsPage, SettingsRow, SettingsSection } from './settings-section';
import { SettingsExpandableRow } from './settings-expandable-row';
import { getSettingsSharedCopy } from '../locales/settings-shared-copy';
import type {
AppSettings,
PersonalizationSettings,
Expand DownExpand Up@@ -69,6 +71,9 @@ export function PersonalizationSettingsPage(props: {
const locale = useUiLocale();
const copy = getSettingsPreferencesCopy(locale).personalization;
const sections = getSettingsPreferencesCopy(locale).sections;
const sharedCopy = getSettingsSharedCopy(locale);
// At most one row in the group is open — the template's own rule.
const [expandedRow, setExpandedRow] = useState<string | null>(null);
// Persist the tone textarea this long after the user stops typing; blur
// flushes immediately regardless.
const TONE_AUTOSAVE_DEBOUNCE_MS = 800;
Expand DownExpand Up@@ -120,33 +125,36 @@ export function PersonalizationSettingsPage(props: {

// Shared persist path for every personalization field. Locale has its own
// last-write-wins lane so unrelated saves cannot steal rollback ownership.
async function persistPersonalization(patch: Partial<PersonalizationSettings>) {
// Returns whether the write landed. The autosaving fields ignore it — they
// have nowhere to put the answer — but a row that closes on save has to
// know, or a failed write collapses the row onto the old value and drops
// the draft with only a toast to show for it.
async function persistPersonalization(patch: Partial<PersonalizationSettings>): Promise<boolean> {
const ticket = ++persistTicketRef.current;
const localeTicket = patch.uiLocale === undefined ? null : ++localePersistTicketRef.current;
persistPendingCountRef.current += 1;
try {
const result = await props.onUpdate({ personalization: patch });
if (!personalizationMountedRef.current) return;
// Saved. An unmounted page just has nowhere left to reflect it.
if (!personalizationMountedRef.current) return true;
if (localeTicket !== null && localeTicket === localePersistTicketRef.current) {
setUiLocale(result.settings.personalization.uiLocale);
}
return true;
} catch (error) {
if (!personalizationMountedRef.current) return;
if (!personalizationMountedRef.current) return false;
if (localeTicket !== null && localeTicket === localePersistTicketRef.current) {
setUiLocale(value.uiLocale);
}
if (ticket === persistTicketRef.current) {
toast.error(copy.saveFailed, settingsActionErrorMessage(error, locale));
}
return false;
} finally {
persistPendingCountRef.current = Math.max(0, persistPendingCountRef.current - 1);
}
}

function flushDisplayName(nextValue: string) {
void persistPersonalization({ displayName: nextValue.trim().slice(0, 60) });
}

function persistLocale(next: UiLocalePreference) {
setUiLocale(next);
void persistPersonalization({ uiLocale: next });
Expand DownExpand Up@@ -177,18 +185,46 @@ export function PersonalizationSettingsPage(props: {
neighboring preferences; the full-width tone field uses the vertical
row variant. */}
<SettingsSection title={sections.identity} description={sections.identityHelp}>
<SettingsField>
{/* A name you set once and then read. A permanently-open input asked
the user to fill in something already filled in, and its blur-save
gave them no way to back out of a change. The row reports the
settled value and opens on demand; Cancel puts the draft back. */}
<SettingsExpandableRow
label={copy.displayName}
value={value.displayName || copy.displayNameUnset}
actionLabel={value.displayName ? copy.displayNameChange : copy.displayNameSet}
isEditing={expandedRow === 'displayName'}
canSave={displayName.trim() !== value.displayName}
saveLabel={sharedCopy.save}
cancelLabel={sharedCopy.cancel}
onEdit={() => {
setDisplayName(value.displayName);
setExpandedRow('displayName');
}}
onCancel={() => {
setDisplayName(value.displayName);
setExpandedRow(null);
}}
onSave={async () => {
// Only close on a write that landed: a failed save leaves the row
// open with the draft intact, which is the promise explicit saving
// makes and blur-autosave could not keep.
if (await persistPersonalization({ displayName: displayName.trim().slice(0, 60) })) {
setExpandedRow(null);
}
}}
>
<TextInput
type="text"
value={displayName}
onChange={(value) => setDisplayName(value.slice(0, 60))}
onBlur={() => flushDisplayName(displayName)}
placeholder={copy.displayNamePlaceholder}
label={copy.displayName}
description={copy.displayNameHelp}
isLabelHidden
width="100%"
/>
</SettingsField>
</SettingsExpandableRow>

{/*
PR-LANG-PREF-0 (WAWQAQ msg `edc9cb41` + kenji `7e532892`
Expand Down
129 changes: 129 additions & 0 deletions apps/desktop/src/renderer/settings/settings-expandable-row.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
import { useEffect, useRef, type ReactNode } from 'react';
import { Button, HStack, Text } from '@astryxdesign/core';
import { SettingsField, SettingsRow } from './settings-section';

/**
* A settled value that becomes a form only when the user asks it to.
*
* The shape is Astryx's own — the `settings-sidebar` template's ExpandableRow
* (`@astryxdesign/cli/templates/pages/settings-sidebar/page.tsx:140-193`): a
* row reports its label and current value with one affordance to change it,
* and swaps in place for the editor plus Save / Cancel. At most one row in a
* group is open at a time, which is the caller's `expandedRow` state.
*
* The shape is copied; the template's implementation is not, because it has
* three problems this component exists to not have:
*
* 1. The template's trigger is `<a href="#">` with `preventDefault`. That
* announces itself as a link, ignores Space, and points nowhere. Opening a
* form is a button.
*
* 2. The template has no focus management at all. Expanding moves focus into
* the editor here, and Save / Cancel return it to the trigger the user came
* from — otherwise collapsing drops focus to the top of the document and a
* keyboard user loses their place in the list.
*
* 3. The template's Cancel only closes; its fields edit live, so there is
* nothing to discard. Ours is a real discard: the caller reverts its draft
* in `onCancel`, which is what makes Cancel mean anything.
*
* Built on `SettingsRow` rather than a bare HStack so the collapsed row keeps
* the Item vocabulary the rest of the surface uses — the `settingsRowEnd`
* width ceiling and the container queries in rows.css apply here too. The
* expanded editor sits in a `SettingsField`, the same full-width block a
* permanently-open input would use.
*/
export function SettingsExpandableRow(props: {
label: string;
/** The settled value, shown while collapsed. */
value: ReactNode;
/** Label for the affordance that opens the editor (更改 / 设置 / 编辑). */
actionLabel: string;
isEditing: boolean;
isDisabled?: boolean;
/** Save stays disabled until the draft actually differs from the value. */
canSave?: boolean;
saveLabel: string;
cancelLabel: string;
onEdit(): void;
onCancel(): void;
onSave(): void | Promise<void>;
children: ReactNode;
}) {
const triggerRef = useRef<HTMLButtonElement>(null);
const editorRef = useRef<HTMLDivElement>(null);
// Only pull focus for a transition the user drove. Without this the row
// would grab focus on first paint whenever a caller mounts already-expanded.
const wasEditingRef = useRef(props.isEditing);

useEffect(() => {
const opened = props.isEditing && !wasEditingRef.current;
const closed = !props.isEditing && wasEditingRef.current;
wasEditingRef.current = props.isEditing;
if (opened) {
// The first control the editor renders, whatever the caller put there.
const first = editorRef.current?.querySelector<HTMLElement>(
'input, textarea, select, button, [contenteditable="true"], [tabindex]:not([tabindex="-1"])',
);
first?.focus();
return;
}
if (closed) triggerRef.current?.focus();
}, [props.isEditing]);

// The collapsed row carries no aria-expanded / aria-controls on purpose.
// Those describe a disclosure — a trigger that stays put while a region
// beside it opens — and this is a mode swap: the trigger unmounts when the
// editor replaces it, so aria-expanded could never reach true and
// aria-controls would name a node that does not exist while collapsed.
// Declaring a contract the DOM never honours is worse than declaring none;
// the focus move is what tells assistive tech the mode changed, and that
// part is measured rather than assumed.
if (!props.isEditing) {
return (
<SettingsRow
label={props.label}
description={props.value}
align="start"
end={(
<Button
ref={triggerRef}
variant="ghost"
size="sm"
isDisabled={props.isDisabled}
onClick={props.onEdit}
label={props.actionLabel}
/>
)}
/>
);
}

return (
<SettingsField>
<div ref={editorRef} className="settingsExpandableEditor">
{/* The label survives the swap. Collapsed, the row's own label names
the value; expanded, the editor would otherwise be an unlabelled
box — the user pressed 更改 on something and must still be able to
see what. The caller hides the control's own label instead, so the
name is stated once. */}
<Text type="body" weight="semibold">{props.label}</Text>
{props.children}
<HStack gap={2}>
<Button
variant="primary"
isDisabled={props.isDisabled || props.canSave === false}
clickAction={() => props.onSave()}
label={props.saveLabel}
/>
<Button
variant="ghost"
isDisabled={props.isDisabled}
onClick={props.onCancel}
label={props.cancelLabel}
/>
</HStack>
</div>
</SettingsField>
);
}
9 changes: 9 additions & 0 deletions apps/desktop/src/renderer/styles/settings/rows.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -141,3 +141,12 @@
min-height: 2rem;
}
}

/* The expanded half of SettingsExpandableRow: the editor the caller supplies,
then its Save / Cancel pair. A grid rather than margins so the gap is the
row group's own rhythm and the editor keeps the field block's full width. */
.settingsExpandableEditor {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--space-3);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,9 @@ export type SettingsPreferencesCopy = {
displayName: string;
displayNameHelp: string;
displayNamePlaceholder: string;
displayNameUnset: string;
displayNameChange: string;
displayNameSet: string;
interfaceLanguage: string;
interfaceLanguageHelp: string;
localeOptions: ReadonlyArray<readonly [UiLocalePreference, string]>;
Expand DownExpand Up@@ -128,7 +131,7 @@ export type SettingsPreferencesCopy = {
const SETTINGS_PREFERENCES_COPY_BY_LOCALE = {
zh: {
personalization: {
saveFailed: '保存失败', displayName: '显示名称', displayNameHelp: 'Maka 在聊天里会以这个名字称呼你。留空就用默认的“你”。', displayNamePlaceholder: '例如:JK',
saveFailed: '保存失败', displayName: '显示名称', displayNameHelp: 'Maka 在聊天里会以这个名字称呼你。留空就用默认的“你”。', displayNamePlaceholder: '例如:JK', displayNameUnset: '未设置,Maka 会称呼你“你”', displayNameChange: '更改', displayNameSet: '设置',
interfaceLanguage: '界面语言', interfaceLanguageHelp: '选择 Maka 界面的显示语言。切换后立即生效,重启后保持。', localeOptions: [['auto', '跟随系统'], ['zh', '中文'], ['en', 'English']],
assistantTone: '助手语气偏好', assistantToneHelp: '最多 500 字,只影响回答的语气和风格。权限确认与安全规则不受影响;改动会自动保存。', assistantTonePlaceholder: '例如:技术严谨、偏简洁、不要 emoji。',
},
Expand DownExpand Up@@ -159,7 +162,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = {
},
en: {
personalization: {
saveFailed: 'Could not save', displayName: 'Display name', displayNameHelp: 'Maka uses this name when addressing you. Leave it blank to use “you”.', displayNamePlaceholder: 'For example: JK', interfaceLanguage: 'Interface language', interfaceLanguageHelp: 'Choose the language used by Maka. Changes apply immediately and persist after restart.', localeOptions: [['auto', 'Follow system'], ['zh', '中文'], ['en', 'English']], assistantTone: 'Assistant tone', assistantToneHelp: 'Up to 500 characters. This changes response style only; permission and safety rules still apply. Changes save automatically.', assistantTonePlaceholder: 'For example: technically rigorous, concise, and no emoji.',
saveFailed: 'Could not save', displayName: 'Display name', displayNameHelp: 'Maka uses this name when addressing you. Leave it blank to use “you”.', displayNamePlaceholder: 'For example: JK', displayNameUnset: 'Not set — Maka will say “you”', displayNameChange: 'Change', displayNameSet: 'Set', interfaceLanguage: 'Interface language', interfaceLanguageHelp: 'Choose the language used by Maka. Changes apply immediately and persist after restart.', localeOptions: [['auto', 'Follow system'], ['zh', '中文'], ['en', 'English']], assistantTone: 'Assistant tone', assistantToneHelp: 'Up to 500 characters. This changes response style only; permission and safety rules still apply. Changes save automatically.', assistantTonePlaceholder: 'For example: technically rigorous, concise, and no emoji.',
},
sections: {
identity: 'Identity', identityHelp: 'How Maka addresses you, plus interface language and response tone.',
Expand Down
56 changes: 46 additions & 10 deletions apps/desktop/src/renderer/settings/appearance-settings-page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ import {
VStack,
} from '@astryxdesign/core';
import { SettingsField, SettingsPage, SettingsRow, SettingsSection } from './settings-section';
import { SettingsExpandableRow } from './settings-expandable-row';
import { getSettingsSharedCopy } from '../locales/settings-shared-copy';
import type {
AppSettings,
PersonalizationSettings,
Expand DownExpand Up@@ -69,6 +71,9 @@ export function PersonalizationSettingsPage(props: {
const locale = useUiLocale();
const copy = getSettingsPreferencesCopy(locale).personalization;
const sections = getSettingsPreferencesCopy(locale).sections;
const sharedCopy = getSettingsSharedCopy(locale);
// At most one row in the group is open — the template's own rule.
const [expandedRow, setExpandedRow] = useState<string | null>(null);
// Persist the tone textarea this long after the user stops typing; blur
// flushes immediately regardless.
const TONE_AUTOSAVE_DEBOUNCE_MS = 800;
Expand DownExpand Up@@ -120,33 +125,36 @@ export function PersonalizationSettingsPage(props: {

// Shared persist path for every personalization field. Locale has its own
// last-write-wins lane so unrelated saves cannot steal rollback ownership.
async function persistPersonalization(patch: Partial<PersonalizationSettings>) {
// Returns whether the write landed. The autosaving fields ignore it — they
// have nowhere to put the answer — but a row that closes on save has to
// know, or a failed write collapses the row onto the old value and drops
// the draft with only a toast to show for it.
async function persistPersonalization(patch: Partial<PersonalizationSettings>): Promise<boolean> {
const ticket = ++persistTicketRef.current;
const localeTicket = patch.uiLocale === undefined ? null : ++localePersistTicketRef.current;
persistPendingCountRef.current += 1;
try {
const result = await props.onUpdate({ personalization: patch });
if (!personalizationMountedRef.current) return;
// Saved. An unmounted page just has nowhere left to reflect it.
if (!personalizationMountedRef.current) return true;
if (localeTicket !== null && localeTicket === localePersistTicketRef.current) {
setUiLocale(result.settings.personalization.uiLocale);
}
return true;
} catch (error) {
if (!personalizationMountedRef.current) return;
if (!personalizationMountedRef.current) return false;
if (localeTicket !== null && localeTicket === localePersistTicketRef.current) {
setUiLocale(value.uiLocale);
}
if (ticket === persistTicketRef.current) {
toast.error(copy.saveFailed, settingsActionErrorMessage(error, locale));
}
return false;
} finally {
persistPendingCountRef.current = Math.max(0, persistPendingCountRef.current - 1);
}
}

function flushDisplayName(nextValue: string) {
void persistPersonalization({ displayName: nextValue.trim().slice(0, 60) });
}

function persistLocale(next: UiLocalePreference) {
setUiLocale(next);
void persistPersonalization({ uiLocale: next });
Expand DownExpand Up@@ -177,18 +185,46 @@ export function PersonalizationSettingsPage(props: {
neighboring preferences; the full-width tone field uses the vertical
row variant. */}
<SettingsSection title={sections.identity} description={sections.identityHelp}>
<SettingsField>
{/* A name you set once and then read. A permanently-open input asked
the user to fill in something already filled in, and its blur-save
gave them no way to back out of a change. The row reports the
settled value and opens on demand; Cancel puts the draft back. */}
<SettingsExpandableRow
label={copy.displayName}
value={value.displayName || copy.displayNameUnset}
actionLabel={value.displayName ? copy.displayNameChange : copy.displayNameSet}
isEditing={expandedRow === 'displayName'}
canSave={displayName.trim() !== value.displayName}
saveLabel={sharedCopy.save}
cancelLabel={sharedCopy.cancel}
onEdit={() => {
setDisplayName(value.displayName);
setExpandedRow('displayName');
}}
onCancel={() => {
setDisplayName(value.displayName);
setExpandedRow(null);
}}
onSave={async () => {
// Only close on a write that landed: a failed save leaves the row
// open with the draft intact, which is the promise explicit saving
// makes and blur-autosave could not keep.
if (await persistPersonalization({ displayName: displayName.trim().slice(0, 60) })) {
setExpandedRow(null);
}
}}
>
<TextInput
type="text"
value={displayName}
onChange={(value) => setDisplayName(value.slice(0, 60))}
onBlur={() => flushDisplayName(displayName)}
placeholder={copy.displayNamePlaceholder}
label={copy.displayName}
description={copy.displayNameHelp}
isLabelHidden
width="100%"
/>
</SettingsField>
</SettingsExpandableRow>

{/*
PR-LANG-PREF-0 (WAWQAQ msg `edc9cb41` + kenji `7e532892`
Expand Down
129 changes: 129 additions & 0 deletions apps/desktop/src/renderer/settings/settings-expandable-row.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
import { useEffect, useRef, type ReactNode } from 'react';
import { Button, HStack, Text } from '@astryxdesign/core';
import { SettingsField, SettingsRow } from './settings-section';

/**
* A settled value that becomes a form only when the user asks it to.
*
* The shape is Astryx's own — the `settings-sidebar` template's ExpandableRow
* (`@astryxdesign/cli/templates/pages/settings-sidebar/page.tsx:140-193`): a
* row reports its label and current value with one affordance to change it,
* and swaps in place for the editor plus Save / Cancel. At most one row in a
* group is open at a time, which is the caller's `expandedRow` state.
*
* The shape is copied; the template's implementation is not, because it has
* three problems this component exists to not have:
*
* 1. The template's trigger is `<a href="#">` with `preventDefault`. That
* announces itself as a link, ignores Space, and points nowhere. Opening a
* form is a button.
*
* 2. The template has no focus management at all. Expanding moves focus into
* the editor here, and Save / Cancel return it to the trigger the user came
* from — otherwise collapsing drops focus to the top of the document and a
* keyboard user loses their place in the list.
*
* 3. The template's Cancel only closes; its fields edit live, so there is
* nothing to discard. Ours is a real discard: the caller reverts its draft
* in `onCancel`, which is what makes Cancel mean anything.
*
* Built on `SettingsRow` rather than a bare HStack so the collapsed row keeps
* the Item vocabulary the rest of the surface uses — the `settingsRowEnd`
* width ceiling and the container queries in rows.css apply here too. The
* expanded editor sits in a `SettingsField`, the same full-width block a
* permanently-open input would use.
*/
export function SettingsExpandableRow(props: {
label: string;
/** The settled value, shown while collapsed. */
value: ReactNode;
/** Label for the affordance that opens the editor (更改 / 设置 / 编辑). */
actionLabel: string;
isEditing: boolean;
isDisabled?: boolean;
/** Save stays disabled until the draft actually differs from the value. */
canSave?: boolean;
saveLabel: string;
cancelLabel: string;
onEdit(): void;
onCancel(): void;
onSave(): void | Promise<void>;
children: ReactNode;
}) {
const triggerRef = useRef<HTMLButtonElement>(null);
const editorRef = useRef<HTMLDivElement>(null);
// Only pull focus for a transition the user drove. Without this the row
// would grab focus on first paint whenever a caller mounts already-expanded.
const wasEditingRef = useRef(props.isEditing);

useEffect(() => {
const opened = props.isEditing && !wasEditingRef.current;
const closed = !props.isEditing && wasEditingRef.current;
wasEditingRef.current = props.isEditing;
if (opened) {
// The first control the editor renders, whatever the caller put there.
const first = editorRef.current?.querySelector<HTMLElement>(
'input, textarea, select, button, [contenteditable="true"], [tabindex]:not([tabindex="-1"])',
);
first?.focus();
return;
}
if (closed) triggerRef.current?.focus();
}, [props.isEditing]);

// The collapsed row carries no aria-expanded / aria-controls on purpose.
// Those describe a disclosure — a trigger that stays put while a region
// beside it opens — and this is a mode swap: the trigger unmounts when the
// editor replaces it, so aria-expanded could never reach true and
// aria-controls would name a node that does not exist while collapsed.
// Declaring a contract the DOM never honours is worse than declaring none;
// the focus move is what tells assistive tech the mode changed, and that
// part is measured rather than assumed.
if (!props.isEditing) {
return (
<SettingsRow
label={props.label}
description={props.value}
align="start"
end={(
<Button
ref={triggerRef}
variant="ghost"
size="sm"
isDisabled={props.isDisabled}
onClick={props.onEdit}
label={props.actionLabel}
/>
)}
/>
);
}

return (
<SettingsField>
<div ref={editorRef} className="settingsExpandableEditor">
{/* The label survives the swap. Collapsed, the row's own label names
the value; expanded, the editor would otherwise be an unlabelled
box — the user pressed 更改 on something and must still be able to
see what. The caller hides the control's own label instead, so the
name is stated once. */}
<Text type="body" weight="semibold">{props.label}</Text>
{props.children}
<HStack gap={2}>
<Button
variant="primary"
isDisabled={props.isDisabled || props.canSave === false}
clickAction={() => props.onSave()}
label={props.saveLabel}
/>
<Button
variant="ghost"
isDisabled={props.isDisabled}
onClick={props.onCancel}
label={props.cancelLabel}
/>
</HStack>
</div>
</SettingsField>
);
}
9 changes: 9 additions & 0 deletions apps/desktop/src/renderer/styles/settings/rows.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -141,3 +141,12 @@
min-height: 2rem;
}
}

/* The expanded half of SettingsExpandableRow: the editor the caller supplies,
then its Save / Cancel pair. A grid rather than margins so the gap is the
row group's own rhythm and the editor keeps the field block's full width. */
.settingsExpandableEditor {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--space-3);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,9 @@ export type SettingsPreferencesCopy = {
displayName: string;
displayNameHelp: string;
displayNamePlaceholder: string;
displayNameUnset: string;
displayNameChange: string;
displayNameSet: string;
interfaceLanguage: string;
interfaceLanguageHelp: string;
localeOptions: ReadonlyArray<readonly [UiLocalePreference, string]>;
Expand DownExpand Up@@ -128,7 +131,7 @@ export type SettingsPreferencesCopy = {
const SETTINGS_PREFERENCES_COPY_BY_LOCALE = {
zh: {
personalization: {
saveFailed: '保存失败', displayName: '显示名称', displayNameHelp: 'Maka 在聊天里会以这个名字称呼你。留空就用默认的“你”。', displayNamePlaceholder: '例如:JK',
saveFailed: '保存失败', displayName: '显示名称', displayNameHelp: 'Maka 在聊天里会以这个名字称呼你。留空就用默认的“你”。', displayNamePlaceholder: '例如:JK', displayNameUnset: '未设置,Maka 会称呼你“你”', displayNameChange: '更改', displayNameSet: '设置',
interfaceLanguage: '界面语言', interfaceLanguageHelp: '选择 Maka 界面的显示语言。切换后立即生效,重启后保持。', localeOptions: [['auto', '跟随系统'], ['zh', '中文'], ['en', 'English']],
assistantTone: '助手语气偏好', assistantToneHelp: '最多 500 字,只影响回答的语气和风格。权限确认与安全规则不受影响;改动会自动保存。', assistantTonePlaceholder: '例如:技术严谨、偏简洁、不要 emoji。',
},
Expand DownExpand Up@@ -159,7 +162,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = {
},
en: {
personalization: {
saveFailed: 'Could not save', displayName: 'Display name', displayNameHelp: 'Maka uses this name when addressing you. Leave it blank to use “you”.', displayNamePlaceholder: 'For example: JK', interfaceLanguage: 'Interface language', interfaceLanguageHelp: 'Choose the language used by Maka. Changes apply immediately and persist after restart.', localeOptions: [['auto', 'Follow system'], ['zh', '中文'], ['en', 'English']], assistantTone: 'Assistant tone', assistantToneHelp: 'Up to 500 characters. This changes response style only; permission and safety rules still apply. Changes save automatically.', assistantTonePlaceholder: 'For example: technically rigorous, concise, and no emoji.',
saveFailed: 'Could not save', displayName: 'Display name', displayNameHelp: 'Maka uses this name when addressing you. Leave it blank to use “you”.', displayNamePlaceholder: 'For example: JK', displayNameUnset: 'Not set — Maka will say “you”', displayNameChange: 'Change', displayNameSet: 'Set', interfaceLanguage: 'Interface language', interfaceLanguageHelp: 'Choose the language used by Maka. Changes apply immediately and persist after restart.', localeOptions: [['auto', 'Follow system'], ['zh', '中文'], ['en', 'English']], assistantTone: 'Assistant tone', assistantToneHelp: 'Up to 500 characters. This changes response style only; permission and safety rules still apply. Changes save automatically.', assistantTonePlaceholder: 'For example: technically rigorous, concise, and no emoji.',
},
sections: {
identity: 'Identity', identityHelp: 'How Maka addresses you, plus interface language and response tone.',
Expand Down
56 changes: 46 additions & 10 deletions apps/desktop/src/renderer/settings/appearance-settings-page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ import {
VStack,
} from '@astryxdesign/core';
import { SettingsField, SettingsPage, SettingsRow, SettingsSection } from './settings-section';
import { SettingsExpandableRow } from './settings-expandable-row';
import { getSettingsSharedCopy } from '../locales/settings-shared-copy';
import type {
AppSettings,
PersonalizationSettings,
Expand DownExpand Up@@ -69,6 +71,9 @@ export function PersonalizationSettingsPage(props: {
const locale = useUiLocale();
const copy = getSettingsPreferencesCopy(locale).personalization;
const sections = getSettingsPreferencesCopy(locale).sections;
const sharedCopy = getSettingsSharedCopy(locale);
// At most one row in the group is open — the template's own rule.
const [expandedRow, setExpandedRow] = useState<string | null>(null);
// Persist the tone textarea this long after the user stops typing; blur
// flushes immediately regardless.
const TONE_AUTOSAVE_DEBOUNCE_MS = 800;
Expand DownExpand Up@@ -120,33 +125,36 @@ export function PersonalizationSettingsPage(props: {

// Shared persist path for every personalization field. Locale has its own
// last-write-wins lane so unrelated saves cannot steal rollback ownership.
async function persistPersonalization(patch: Partial<PersonalizationSettings>) {
// Returns whether the write landed. The autosaving fields ignore it — they
// have nowhere to put the answer — but a row that closes on save has to
// know, or a failed write collapses the row onto the old value and drops
// the draft with only a toast to show for it.
async function persistPersonalization(patch: Partial<PersonalizationSettings>): Promise<boolean> {
const ticket = ++persistTicketRef.current;
const localeTicket = patch.uiLocale === undefined ? null : ++localePersistTicketRef.current;
persistPendingCountRef.current += 1;
try {
const result = await props.onUpdate({ personalization: patch });
if (!personalizationMountedRef.current) return;
// Saved. An unmounted page just has nowhere left to reflect it.
if (!personalizationMountedRef.current) return true;
if (localeTicket !== null && localeTicket === localePersistTicketRef.current) {
setUiLocale(result.settings.personalization.uiLocale);
}
return true;
} catch (error) {
if (!personalizationMountedRef.current) return;
if (!personalizationMountedRef.current) return false;
if (localeTicket !== null && localeTicket === localePersistTicketRef.current) {
setUiLocale(value.uiLocale);
}
if (ticket === persistTicketRef.current) {
toast.error(copy.saveFailed, settingsActionErrorMessage(error, locale));
}
return false;
} finally {
persistPendingCountRef.current = Math.max(0, persistPendingCountRef.current - 1);
}
}

function flushDisplayName(nextValue: string) {
void persistPersonalization({ displayName: nextValue.trim().slice(0, 60) });
}

function persistLocale(next: UiLocalePreference) {
setUiLocale(next);
void persistPersonalization({ uiLocale: next });
Expand DownExpand Up@@ -177,18 +185,46 @@ export function PersonalizationSettingsPage(props: {
neighboring preferences; the full-width tone field uses the vertical
row variant. */}
<SettingsSection title={sections.identity} description={sections.identityHelp}>
<SettingsField>
{/* A name you set once and then read. A permanently-open input asked
the user to fill in something already filled in, and its blur-save
gave them no way to back out of a change. The row reports the
settled value and opens on demand; Cancel puts the draft back. */}
<SettingsExpandableRow
label={copy.displayName}
value={value.displayName || copy.displayNameUnset}
actionLabel={value.displayName ? copy.displayNameChange : copy.displayNameSet}
isEditing={expandedRow === 'displayName'}
canSave={displayName.trim() !== value.displayName}
saveLabel={sharedCopy.save}
cancelLabel={sharedCopy.cancel}
onEdit={() => {
setDisplayName(value.displayName);
setExpandedRow('displayName');
}}
onCancel={() => {
setDisplayName(value.displayName);
setExpandedRow(null);
}}
onSave={async () => {
// Only close on a write that landed: a failed save leaves the row
// open with the draft intact, which is the promise explicit saving
// makes and blur-autosave could not keep.
if (await persistPersonalization({ displayName: displayName.trim().slice(0, 60) })) {
setExpandedRow(null);
}
}}
>
<TextInput
type="text"
value={displayName}
onChange={(value) => setDisplayName(value.slice(0, 60))}
onBlur={() => flushDisplayName(displayName)}
placeholder={copy.displayNamePlaceholder}
label={copy.displayName}
description={copy.displayNameHelp}
isLabelHidden
width="100%"
/>
</SettingsField>
</SettingsExpandableRow>

{/*
PR-LANG-PREF-0 (WAWQAQ msg `edc9cb41` + kenji `7e532892`
Expand Down
129 changes: 129 additions & 0 deletions apps/desktop/src/renderer/settings/settings-expandable-row.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
import { useEffect, useRef, type ReactNode } from 'react';
import { Button, HStack, Text } from '@astryxdesign/core';
import { SettingsField, SettingsRow } from './settings-section';

/**
* A settled value that becomes a form only when the user asks it to.
*
* The shape is Astryx's own — the `settings-sidebar` template's ExpandableRow
* (`@astryxdesign/cli/templates/pages/settings-sidebar/page.tsx:140-193`): a
* row reports its label and current value with one affordance to change it,
* and swaps in place for the editor plus Save / Cancel. At most one row in a
* group is open at a time, which is the caller's `expandedRow` state.
*
* The shape is copied; the template's implementation is not, because it has
* three problems this component exists to not have:
*
* 1. The template's trigger is `<a href="#">` with `preventDefault`. That
* announces itself as a link, ignores Space, and points nowhere. Opening a
* form is a button.
*
* 2. The template has no focus management at all. Expanding moves focus into
* the editor here, and Save / Cancel return it to the trigger the user came
* from — otherwise collapsing drops focus to the top of the document and a
* keyboard user loses their place in the list.
*
* 3. The template's Cancel only closes; its fields edit live, so there is
* nothing to discard. Ours is a real discard: the caller reverts its draft
* in `onCancel`, which is what makes Cancel mean anything.
*
* Built on `SettingsRow` rather than a bare HStack so the collapsed row keeps
* the Item vocabulary the rest of the surface uses — the `settingsRowEnd`
* width ceiling and the container queries in rows.css apply here too. The
* expanded editor sits in a `SettingsField`, the same full-width block a
* permanently-open input would use.
*/
export function SettingsExpandableRow(props: {
label: string;
/** The settled value, shown while collapsed. */
value: ReactNode;
/** Label for the affordance that opens the editor (更改 / 设置 / 编辑). */
actionLabel: string;
isEditing: boolean;
isDisabled?: boolean;
/** Save stays disabled until the draft actually differs from the value. */
canSave?: boolean;
saveLabel: string;
cancelLabel: string;
onEdit(): void;
onCancel(): void;
onSave(): void | Promise<void>;
children: ReactNode;
}) {
const triggerRef = useRef<HTMLButtonElement>(null);
const editorRef = useRef<HTMLDivElement>(null);
// Only pull focus for a transition the user drove. Without this the row
// would grab focus on first paint whenever a caller mounts already-expanded.
const wasEditingRef = useRef(props.isEditing);

useEffect(() => {
const opened = props.isEditing && !wasEditingRef.current;
const closed = !props.isEditing && wasEditingRef.current;
wasEditingRef.current = props.isEditing;
if (opened) {
// The first control the editor renders, whatever the caller put there.
const first = editorRef.current?.querySelector<HTMLElement>(
'input, textarea, select, button, [contenteditable="true"], [tabindex]:not([tabindex="-1"])',
);
first?.focus();
return;
}
if (closed) triggerRef.current?.focus();
}, [props.isEditing]);

// The collapsed row carries no aria-expanded / aria-controls on purpose.
// Those describe a disclosure — a trigger that stays put while a region
// beside it opens — and this is a mode swap: the trigger unmounts when the
// editor replaces it, so aria-expanded could never reach true and
// aria-controls would name a node that does not exist while collapsed.
// Declaring a contract the DOM never honours is worse than declaring none;
// the focus move is what tells assistive tech the mode changed, and that
// part is measured rather than assumed.
if (!props.isEditing) {
return (
<SettingsRow
label={props.label}
description={props.value}
align="start"
end={(
<Button
ref={triggerRef}
variant="ghost"
size="sm"
isDisabled={props.isDisabled}
onClick={props.onEdit}
label={props.actionLabel}
/>
)}
/>
);
}

return (
<SettingsField>
<div ref={editorRef} className="settingsExpandableEditor">
{/* The label survives the swap. Collapsed, the row's own label names
the value; expanded, the editor would otherwise be an unlabelled
box — the user pressed 更改 on something and must still be able to
see what. The caller hides the control's own label instead, so the
name is stated once. */}
<Text type="body" weight="semibold">{props.label}</Text>
{props.children}
<HStack gap={2}>
<Button
variant="primary"
isDisabled={props.isDisabled || props.canSave === false}
clickAction={() => props.onSave()}
label={props.saveLabel}
/>
<Button
variant="ghost"
isDisabled={props.isDisabled}
onClick={props.onCancel}
label={props.cancelLabel}
/>
</HStack>
</div>
</SettingsField>
);
}
9 changes: 9 additions & 0 deletions apps/desktop/src/renderer/styles/settings/rows.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -141,3 +141,12 @@
min-height: 2rem;
}
}

/* The expanded half of SettingsExpandableRow: the editor the caller supplies,
then its Save / Cancel pair. A grid rather than margins so the gap is the
row group's own rhythm and the editor keeps the field block's full width. */
.settingsExpandableEditor {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--space-3);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,9 @@ export type SettingsPreferencesCopy = {
displayName: string;
displayNameHelp: string;
displayNamePlaceholder: string;
displayNameUnset: string;
displayNameChange: string;
displayNameSet: string;
interfaceLanguage: string;
interfaceLanguageHelp: string;
localeOptions: ReadonlyArray<readonly [UiLocalePreference, string]>;
Expand DownExpand Up@@ -128,7 +131,7 @@ export type SettingsPreferencesCopy = {
const SETTINGS_PREFERENCES_COPY_BY_LOCALE = {
zh: {
personalization: {
saveFailed: '保存失败', displayName: '显示名称', displayNameHelp: 'Maka 在聊天里会以这个名字称呼你。留空就用默认的“你”。', displayNamePlaceholder: '例如:JK',
saveFailed: '保存失败', displayName: '显示名称', displayNameHelp: 'Maka 在聊天里会以这个名字称呼你。留空就用默认的“你”。', displayNamePlaceholder: '例如:JK', displayNameUnset: '未设置,Maka 会称呼你“你”', displayNameChange: '更改', displayNameSet: '设置',
interfaceLanguage: '界面语言', interfaceLanguageHelp: '选择 Maka 界面的显示语言。切换后立即生效,重启后保持。', localeOptions: [['auto', '跟随系统'], ['zh', '中文'], ['en', 'English']],
assistantTone: '助手语气偏好', assistantToneHelp: '最多 500 字,只影响回答的语气和风格。权限确认与安全规则不受影响;改动会自动保存。', assistantTonePlaceholder: '例如:技术严谨、偏简洁、不要 emoji。',
},
Expand DownExpand Up@@ -159,7 +162,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = {
},
en: {
personalization: {
saveFailed: 'Could not save', displayName: 'Display name', displayNameHelp: 'Maka uses this name when addressing you. Leave it blank to use “you”.', displayNamePlaceholder: 'For example: JK', interfaceLanguage: 'Interface language', interfaceLanguageHelp: 'Choose the language used by Maka. Changes apply immediately and persist after restart.', localeOptions: [['auto', 'Follow system'], ['zh', '中文'], ['en', 'English']], assistantTone: 'Assistant tone', assistantToneHelp: 'Up to 500 characters. This changes response style only; permission and safety rules still apply. Changes save automatically.', assistantTonePlaceholder: 'For example: technically rigorous, concise, and no emoji.',
saveFailed: 'Could not save', displayName: 'Display name', displayNameHelp: 'Maka uses this name when addressing you. Leave it blank to use “you”.', displayNamePlaceholder: 'For example: JK', displayNameUnset: 'Not set — Maka will say “you”', displayNameChange: 'Change', displayNameSet: 'Set', interfaceLanguage: 'Interface language', interfaceLanguageHelp: 'Choose the language used by Maka. Changes apply immediately and persist after restart.', localeOptions: [['auto', 'Follow system'], ['zh', '中文'], ['en', 'English']], assistantTone: 'Assistant tone', assistantToneHelp: 'Up to 500 characters. This changes response style only; permission and safety rules still apply. Changes save automatically.', assistantTonePlaceholder: 'For example: technically rigorous, concise, and no emoji.',
},
sections: {
identity: 'Identity', identityHelp: 'How Maka addresses you, plus interface language and response tone.',
Expand Down
56 changes: 46 additions & 10 deletions apps/desktop/src/renderer/settings/appearance-settings-page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ import {
VStack,
} from '@astryxdesign/core';
import { SettingsField, SettingsPage, SettingsRow, SettingsSection } from './settings-section';
import { SettingsExpandableRow } from './settings-expandable-row';
import { getSettingsSharedCopy } from '../locales/settings-shared-copy';
import type {
AppSettings,
PersonalizationSettings,
Expand DownExpand Up@@ -69,6 +71,9 @@ export function PersonalizationSettingsPage(props: {
const locale = useUiLocale();
const copy = getSettingsPreferencesCopy(locale).personalization;
const sections = getSettingsPreferencesCopy(locale).sections;
const sharedCopy = getSettingsSharedCopy(locale);
// At most one row in the group is open — the template's own rule.
const [expandedRow, setExpandedRow] = useState<string | null>(null);
// Persist the tone textarea this long after the user stops typing; blur
// flushes immediately regardless.
const TONE_AUTOSAVE_DEBOUNCE_MS = 800;
Expand DownExpand Up@@ -120,33 +125,36 @@ export function PersonalizationSettingsPage(props: {

// Shared persist path for every personalization field. Locale has its own
// last-write-wins lane so unrelated saves cannot steal rollback ownership.
async function persistPersonalization(patch: Partial<PersonalizationSettings>) {
// Returns whether the write landed. The autosaving fields ignore it — they
// have nowhere to put the answer — but a row that closes on save has to
// know, or a failed write collapses the row onto the old value and drops
// the draft with only a toast to show for it.
async function persistPersonalization(patch: Partial<PersonalizationSettings>): Promise<boolean> {
const ticket = ++persistTicketRef.current;
const localeTicket = patch.uiLocale === undefined ? null : ++localePersistTicketRef.current;
persistPendingCountRef.current += 1;
try {
const result = await props.onUpdate({ personalization: patch });
if (!personalizationMountedRef.current) return;
// Saved. An unmounted page just has nowhere left to reflect it.
if (!personalizationMountedRef.current) return true;
if (localeTicket !== null && localeTicket === localePersistTicketRef.current) {
setUiLocale(result.settings.personalization.uiLocale);
}
return true;
} catch (error) {
if (!personalizationMountedRef.current) return;
if (!personalizationMountedRef.current) return false;
if (localeTicket !== null && localeTicket === localePersistTicketRef.current) {
setUiLocale(value.uiLocale);
}
if (ticket === persistTicketRef.current) {
toast.error(copy.saveFailed, settingsActionErrorMessage(error, locale));
}
return false;
} finally {
persistPendingCountRef.current = Math.max(0, persistPendingCountRef.current - 1);
}
}

function flushDisplayName(nextValue: string) {
void persistPersonalization({ displayName: nextValue.trim().slice(0, 60) });
}

function persistLocale(next: UiLocalePreference) {
setUiLocale(next);
void persistPersonalization({ uiLocale: next });
Expand DownExpand Up@@ -177,18 +185,46 @@ export function PersonalizationSettingsPage(props: {
neighboring preferences; the full-width tone field uses the vertical
row variant. */}
<SettingsSection title={sections.identity} description={sections.identityHelp}>
<SettingsField>
{/* A name you set once and then read. A permanently-open input asked
the user to fill in something already filled in, and its blur-save
gave them no way to back out of a change. The row reports the
settled value and opens on demand; Cancel puts the draft back. */}
<SettingsExpandableRow
label={copy.displayName}
value={value.displayName || copy.displayNameUnset}
actionLabel={value.displayName ? copy.displayNameChange : copy.displayNameSet}
isEditing={expandedRow === 'displayName'}
canSave={displayName.trim() !== value.displayName}
saveLabel={sharedCopy.save}
cancelLabel={sharedCopy.cancel}
onEdit={() => {
setDisplayName(value.displayName);
setExpandedRow('displayName');
}}
onCancel={() => {
setDisplayName(value.displayName);
setExpandedRow(null);
}}
onSave={async () => {
// Only close on a write that landed: a failed save leaves the row
// open with the draft intact, which is the promise explicit saving
// makes and blur-autosave could not keep.
if (await persistPersonalization({ displayName: displayName.trim().slice(0, 60) })) {
setExpandedRow(null);
}
}}
>
<TextInput
type="text"
value={displayName}
onChange={(value) => setDisplayName(value.slice(0, 60))}
onBlur={() => flushDisplayName(displayName)}
placeholder={copy.displayNamePlaceholder}
label={copy.displayName}
description={copy.displayNameHelp}
isLabelHidden
width="100%"
/>
</SettingsField>
</SettingsExpandableRow>

{/*
PR-LANG-PREF-0 (WAWQAQ msg `edc9cb41` + kenji `7e532892`
Expand Down
129 changes: 129 additions & 0 deletions apps/desktop/src/renderer/settings/settings-expandable-row.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
import { useEffect, useRef, type ReactNode } from 'react';
import { Button, HStack, Text } from '@astryxdesign/core';
import { SettingsField, SettingsRow } from './settings-section';

/**
* A settled value that becomes a form only when the user asks it to.
*
* The shape is Astryx's own — the `settings-sidebar` template's ExpandableRow
* (`@astryxdesign/cli/templates/pages/settings-sidebar/page.tsx:140-193`): a
* row reports its label and current value with one affordance to change it,
* and swaps in place for the editor plus Save / Cancel. At most one row in a
* group is open at a time, which is the caller's `expandedRow` state.
*
* The shape is copied; the template's implementation is not, because it has
* three problems this component exists to not have:
*
* 1. The template's trigger is `<a href="#">` with `preventDefault`. That
* announces itself as a link, ignores Space, and points nowhere. Opening a
* form is a button.
*
* 2. The template has no focus management at all. Expanding moves focus into
* the editor here, and Save / Cancel return it to the trigger the user came
* from — otherwise collapsing drops focus to the top of the document and a
* keyboard user loses their place in the list.
*
* 3. The template's Cancel only closes; its fields edit live, so there is
* nothing to discard. Ours is a real discard: the caller reverts its draft
* in `onCancel`, which is what makes Cancel mean anything.
*
* Built on `SettingsRow` rather than a bare HStack so the collapsed row keeps
* the Item vocabulary the rest of the surface uses — the `settingsRowEnd`
* width ceiling and the container queries in rows.css apply here too. The
* expanded editor sits in a `SettingsField`, the same full-width block a
* permanently-open input would use.
*/
export function SettingsExpandableRow(props: {
label: string;
/** The settled value, shown while collapsed. */
value: ReactNode;
/** Label for the affordance that opens the editor (更改 / 设置 / 编辑). */
actionLabel: string;
isEditing: boolean;
isDisabled?: boolean;
/** Save stays disabled until the draft actually differs from the value. */
canSave?: boolean;
saveLabel: string;
cancelLabel: string;
onEdit(): void;
onCancel(): void;
onSave(): void | Promise<void>;
children: ReactNode;
}) {
const triggerRef = useRef<HTMLButtonElement>(null);
const editorRef = useRef<HTMLDivElement>(null);
// Only pull focus for a transition the user drove. Without this the row
// would grab focus on first paint whenever a caller mounts already-expanded.
const wasEditingRef = useRef(props.isEditing);

useEffect(() => {
const opened = props.isEditing && !wasEditingRef.current;
const closed = !props.isEditing && wasEditingRef.current;
wasEditingRef.current = props.isEditing;
if (opened) {
// The first control the editor renders, whatever the caller put there.
const first = editorRef.current?.querySelector<HTMLElement>(
'input, textarea, select, button, [contenteditable="true"], [tabindex]:not([tabindex="-1"])',
);
first?.focus();
return;
}
if (closed) triggerRef.current?.focus();
}, [props.isEditing]);

// The collapsed row carries no aria-expanded / aria-controls on purpose.
// Those describe a disclosure — a trigger that stays put while a region
// beside it opens — and this is a mode swap: the trigger unmounts when the
// editor replaces it, so aria-expanded could never reach true and
// aria-controls would name a node that does not exist while collapsed.
// Declaring a contract the DOM never honours is worse than declaring none;
// the focus move is what tells assistive tech the mode changed, and that
// part is measured rather than assumed.
if (!props.isEditing) {
return (
<SettingsRow
label={props.label}
description={props.value}
align="start"
end={(
<Button
ref={triggerRef}
variant="ghost"
size="sm"
isDisabled={props.isDisabled}
onClick={props.onEdit}
label={props.actionLabel}
/>
)}
/>
);
}

return (
<SettingsField>
<div ref={editorRef} className="settingsExpandableEditor">
{/* The label survives the swap. Collapsed, the row's own label names
the value; expanded, the editor would otherwise be an unlabelled
box — the user pressed 更改 on something and must still be able to
see what. The caller hides the control's own label instead, so the
name is stated once. */}
<Text type="body" weight="semibold">{props.label}</Text>
{props.children}
<HStack gap={2}>
<Button
variant="primary"
isDisabled={props.isDisabled || props.canSave === false}
clickAction={() => props.onSave()}
label={props.saveLabel}
/>
<Button
variant="ghost"
isDisabled={props.isDisabled}
onClick={props.onCancel}
label={props.cancelLabel}
/>
</HStack>
</div>
</SettingsField>
);
}
9 changes: 9 additions & 0 deletions apps/desktop/src/renderer/styles/settings/rows.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -141,3 +141,12 @@
min-height: 2rem;
}
}

/* The expanded half of SettingsExpandableRow: the editor the caller supplies,
then its Save / Cancel pair. A grid rather than margins so the gap is the
row group's own rhythm and the editor keeps the field block's full width. */
.settingsExpandableEditor {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--space-3);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,9 @@ export type SettingsPreferencesCopy = {
displayName: string;
displayNameHelp: string;
displayNamePlaceholder: string;
displayNameUnset: string;
displayNameChange: string;
displayNameSet: string;
interfaceLanguage: string;
interfaceLanguageHelp: string;
localeOptions: ReadonlyArray<readonly [UiLocalePreference, string]>;
Expand DownExpand Up@@ -128,7 +131,7 @@ export type SettingsPreferencesCopy = {
const SETTINGS_PREFERENCES_COPY_BY_LOCALE = {
zh: {
personalization: {
saveFailed: '保存失败', displayName: '显示名称', displayNameHelp: 'Maka 在聊天里会以这个名字称呼你。留空就用默认的“你”。', displayNamePlaceholder: '例如:JK',
saveFailed: '保存失败', displayName: '显示名称', displayNameHelp: 'Maka 在聊天里会以这个名字称呼你。留空就用默认的“你”。', displayNamePlaceholder: '例如:JK', displayNameUnset: '未设置,Maka 会称呼你“你”', displayNameChange: '更改', displayNameSet: '设置',
interfaceLanguage: '界面语言', interfaceLanguageHelp: '选择 Maka 界面的显示语言。切换后立即生效,重启后保持。', localeOptions: [['auto', '跟随系统'], ['zh', '中文'], ['en', 'English']],
assistantTone: '助手语气偏好', assistantToneHelp: '最多 500 字,只影响回答的语气和风格。权限确认与安全规则不受影响;改动会自动保存。', assistantTonePlaceholder: '例如:技术严谨、偏简洁、不要 emoji。',
},
Expand DownExpand Up@@ -159,7 +162,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = {
},
en: {
personalization: {
saveFailed: 'Could not save', displayName: 'Display name', displayNameHelp: 'Maka uses this name when addressing you. Leave it blank to use “you”.', displayNamePlaceholder: 'For example: JK', interfaceLanguage: 'Interface language', interfaceLanguageHelp: 'Choose the language used by Maka. Changes apply immediately and persist after restart.', localeOptions: [['auto', 'Follow system'], ['zh', '中文'], ['en', 'English']], assistantTone: 'Assistant tone', assistantToneHelp: 'Up to 500 characters. This changes response style only; permission and safety rules still apply. Changes save automatically.', assistantTonePlaceholder: 'For example: technically rigorous, concise, and no emoji.',
saveFailed: 'Could not save', displayName: 'Display name', displayNameHelp: 'Maka uses this name when addressing you. Leave it blank to use “you”.', displayNamePlaceholder: 'For example: JK', displayNameUnset: 'Not set — Maka will say “you”', displayNameChange: 'Change', displayNameSet: 'Set', interfaceLanguage: 'Interface language', interfaceLanguageHelp: 'Choose the language used by Maka. Changes apply immediately and persist after restart.', localeOptions: [['auto', 'Follow system'], ['zh', '中文'], ['en', 'English']], assistantTone: 'Assistant tone', assistantToneHelp: 'Up to 500 characters. This changes response style only; permission and safety rules still apply. Changes save automatically.', assistantTonePlaceholder: 'For example: technically rigorous, concise, and no emoji.',
},
sections: {
identity: 'Identity', identityHelp: 'How Maka addresses you, plus interface language and response tone.',
Expand Down
56 changes: 46 additions & 10 deletions apps/desktop/src/renderer/settings/appearance-settings-page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ import {
VStack,
} from '@astryxdesign/core';
import { SettingsField, SettingsPage, SettingsRow, SettingsSection } from './settings-section';
import { SettingsExpandableRow } from './settings-expandable-row';
import { getSettingsSharedCopy } from '../locales/settings-shared-copy';
import type {
AppSettings,
PersonalizationSettings,
Expand DownExpand Up@@ -69,6 +71,9 @@ export function PersonalizationSettingsPage(props: {
const locale = useUiLocale();
const copy = getSettingsPreferencesCopy(locale).personalization;
const sections = getSettingsPreferencesCopy(locale).sections;
const sharedCopy = getSettingsSharedCopy(locale);
// At most one row in the group is open — the template's own rule.
const [expandedRow, setExpandedRow] = useState<string | null>(null);
// Persist the tone textarea this long after the user stops typing; blur
// flushes immediately regardless.
const TONE_AUTOSAVE_DEBOUNCE_MS = 800;
Expand DownExpand Up@@ -120,33 +125,36 @@ export function PersonalizationSettingsPage(props: {

// Shared persist path for every personalization field. Locale has its own
// last-write-wins lane so unrelated saves cannot steal rollback ownership.
async function persistPersonalization(patch: Partial<PersonalizationSettings>) {
// Returns whether the write landed. The autosaving fields ignore it — they
// have nowhere to put the answer — but a row that closes on save has to
// know, or a failed write collapses the row onto the old value and drops
// the draft with only a toast to show for it.
async function persistPersonalization(patch: Partial<PersonalizationSettings>): Promise<boolean> {
const ticket = ++persistTicketRef.current;
const localeTicket = patch.uiLocale === undefined ? null : ++localePersistTicketRef.current;
persistPendingCountRef.current += 1;
try {
const result = await props.onUpdate({ personalization: patch });
if (!personalizationMountedRef.current) return;
// Saved. An unmounted page just has nowhere left to reflect it.
if (!personalizationMountedRef.current) return true;
if (localeTicket !== null && localeTicket === localePersistTicketRef.current) {
setUiLocale(result.settings.personalization.uiLocale);
}
return true;
} catch (error) {
if (!personalizationMountedRef.current) return;
if (!personalizationMountedRef.current) return false;
if (localeTicket !== null && localeTicket === localePersistTicketRef.current) {
setUiLocale(value.uiLocale);
}
if (ticket === persistTicketRef.current) {
toast.error(copy.saveFailed, settingsActionErrorMessage(error, locale));
}
return false;
} finally {
persistPendingCountRef.current = Math.max(0, persistPendingCountRef.current - 1);
}
}

function flushDisplayName(nextValue: string) {
void persistPersonalization({ displayName: nextValue.trim().slice(0, 60) });
}

function persistLocale(next: UiLocalePreference) {
setUiLocale(next);
void persistPersonalization({ uiLocale: next });
Expand DownExpand Up@@ -177,18 +185,46 @@ export function PersonalizationSettingsPage(props: {
neighboring preferences; the full-width tone field uses the vertical
row variant. */}
<SettingsSection title={sections.identity} description={sections.identityHelp}>
<SettingsField>
{/* A name you set once and then read. A permanently-open input asked
the user to fill in something already filled in, and its blur-save
gave them no way to back out of a change. The row reports the
settled value and opens on demand; Cancel puts the draft back. */}
<SettingsExpandableRow
label={copy.displayName}
value={value.displayName || copy.displayNameUnset}
actionLabel={value.displayName ? copy.displayNameChange : copy.displayNameSet}
isEditing={expandedRow === 'displayName'}
canSave={displayName.trim() !== value.displayName}
saveLabel={sharedCopy.save}
cancelLabel={sharedCopy.cancel}
onEdit={() => {
setDisplayName(value.displayName);
setExpandedRow('displayName');
}}
onCancel={() => {
setDisplayName(value.displayName);
setExpandedRow(null);
}}
onSave={async () => {
// Only close on a write that landed: a failed save leaves the row
// open with the draft intact, which is the promise explicit saving
// makes and blur-autosave could not keep.
if (await persistPersonalization({ displayName: displayName.trim().slice(0, 60) })) {
setExpandedRow(null);
}
}}
>
<TextInput
type="text"
value={displayName}
onChange={(value) => setDisplayName(value.slice(0, 60))}
onBlur={() => flushDisplayName(displayName)}
placeholder={copy.displayNamePlaceholder}
label={copy.displayName}
description={copy.displayNameHelp}
isLabelHidden
width="100%"
/>
</SettingsField>
</SettingsExpandableRow>

{/*
PR-LANG-PREF-0 (WAWQAQ msg `edc9cb41` + kenji `7e532892`
Expand Down
129 changes: 129 additions & 0 deletions apps/desktop/src/renderer/settings/settings-expandable-row.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
import { useEffect, useRef, type ReactNode } from 'react';
import { Button, HStack, Text } from '@astryxdesign/core';
import { SettingsField, SettingsRow } from './settings-section';

/**
* A settled value that becomes a form only when the user asks it to.
*
* The shape is Astryx's own — the `settings-sidebar` template's ExpandableRow
* (`@astryxdesign/cli/templates/pages/settings-sidebar/page.tsx:140-193`): a
* row reports its label and current value with one affordance to change it,
* and swaps in place for the editor plus Save / Cancel. At most one row in a
* group is open at a time, which is the caller's `expandedRow` state.
*
* The shape is copied; the template's implementation is not, because it has
* three problems this component exists to not have:
*
* 1. The template's trigger is `<a href="#">` with `preventDefault`. That
* announces itself as a link, ignores Space, and points nowhere. Opening a
* form is a button.
*
* 2. The template has no focus management at all. Expanding moves focus into
* the editor here, and Save / Cancel return it to the trigger the user came
* from — otherwise collapsing drops focus to the top of the document and a
* keyboard user loses their place in the list.
*
* 3. The template's Cancel only closes; its fields edit live, so there is
* nothing to discard. Ours is a real discard: the caller reverts its draft
* in `onCancel`, which is what makes Cancel mean anything.
*
* Built on `SettingsRow` rather than a bare HStack so the collapsed row keeps
* the Item vocabulary the rest of the surface uses — the `settingsRowEnd`
* width ceiling and the container queries in rows.css apply here too. The
* expanded editor sits in a `SettingsField`, the same full-width block a
* permanently-open input would use.
*/
export function SettingsExpandableRow(props: {
label: string;
/** The settled value, shown while collapsed. */
value: ReactNode;
/** Label for the affordance that opens the editor (更改 / 设置 / 编辑). */
actionLabel: string;
isEditing: boolean;
isDisabled?: boolean;
/** Save stays disabled until the draft actually differs from the value. */
canSave?: boolean;
saveLabel: string;
cancelLabel: string;
onEdit(): void;
onCancel(): void;
onSave(): void | Promise<void>;
children: ReactNode;
}) {
const triggerRef = useRef<HTMLButtonElement>(null);
const editorRef = useRef<HTMLDivElement>(null);
// Only pull focus for a transition the user drove. Without this the row
// would grab focus on first paint whenever a caller mounts already-expanded.
const wasEditingRef = useRef(props.isEditing);

useEffect(() => {
const opened = props.isEditing && !wasEditingRef.current;
const closed = !props.isEditing && wasEditingRef.current;
wasEditingRef.current = props.isEditing;
if (opened) {
// The first control the editor renders, whatever the caller put there.
const first = editorRef.current?.querySelector<HTMLElement>(
'input, textarea, select, button, [contenteditable="true"], [tabindex]:not([tabindex="-1"])',
);
first?.focus();
return;
}
if (closed) triggerRef.current?.focus();
}, [props.isEditing]);

// The collapsed row carries no aria-expanded / aria-controls on purpose.
// Those describe a disclosure — a trigger that stays put while a region
// beside it opens — and this is a mode swap: the trigger unmounts when the
// editor replaces it, so aria-expanded could never reach true and
// aria-controls would name a node that does not exist while collapsed.
// Declaring a contract the DOM never honours is worse than declaring none;
// the focus move is what tells assistive tech the mode changed, and that
// part is measured rather than assumed.
if (!props.isEditing) {
return (
<SettingsRow
label={props.label}
description={props.value}
align="start"
end={(
<Button
ref={triggerRef}
variant="ghost"
size="sm"
isDisabled={props.isDisabled}
onClick={props.onEdit}
label={props.actionLabel}
/>
)}
/>
);
}

return (
<SettingsField>
<div ref={editorRef} className="settingsExpandableEditor">
{/* The label survives the swap. Collapsed, the row's own label names
the value; expanded, the editor would otherwise be an unlabelled
box — the user pressed 更改 on something and must still be able to
see what. The caller hides the control's own label instead, so the
name is stated once. */}
<Text type="body" weight="semibold">{props.label}</Text>
{props.children}
<HStack gap={2}>
<Button
variant="primary"
isDisabled={props.isDisabled || props.canSave === false}
clickAction={() => props.onSave()}
label={props.saveLabel}
/>
<Button
variant="ghost"
isDisabled={props.isDisabled}
onClick={props.onCancel}
label={props.cancelLabel}
/>
</HStack>
</div>
</SettingsField>
);
}
9 changes: 9 additions & 0 deletions apps/desktop/src/renderer/styles/settings/rows.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -141,3 +141,12 @@
min-height: 2rem;
}
}

/* The expanded half of SettingsExpandableRow: the editor the caller supplies,
then its Save / Cancel pair. A grid rather than margins so the gap is the
row group's own rhythm and the editor keeps the field block's full width. */
.settingsExpandableEditor {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--space-3);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,9 @@ export type SettingsPreferencesCopy = {
displayName: string;
displayNameHelp: string;
displayNamePlaceholder: string;
displayNameUnset: string;
displayNameChange: string;
displayNameSet: string;
interfaceLanguage: string;
interfaceLanguageHelp: string;
localeOptions: ReadonlyArray<readonly [UiLocalePreference, string]>;
Expand DownExpand Up@@ -128,7 +131,7 @@ export type SettingsPreferencesCopy = {
const SETTINGS_PREFERENCES_COPY_BY_LOCALE = {
zh: {
personalization: {
saveFailed: '保存失败', displayName: '显示名称', displayNameHelp: 'Maka 在聊天里会以这个名字称呼你。留空就用默认的“你”。', displayNamePlaceholder: '例如:JK',
saveFailed: '保存失败', displayName: '显示名称', displayNameHelp: 'Maka 在聊天里会以这个名字称呼你。留空就用默认的“你”。', displayNamePlaceholder: '例如:JK', displayNameUnset: '未设置,Maka 会称呼你“你”', displayNameChange: '更改', displayNameSet: '设置',
interfaceLanguage: '界面语言', interfaceLanguageHelp: '选择 Maka 界面的显示语言。切换后立即生效,重启后保持。', localeOptions: [['auto', '跟随系统'], ['zh', '中文'], ['en', 'English']],
assistantTone: '助手语气偏好', assistantToneHelp: '最多 500 字,只影响回答的语气和风格。权限确认与安全规则不受影响;改动会自动保存。', assistantTonePlaceholder: '例如:技术严谨、偏简洁、不要 emoji。',
},
Expand DownExpand Up@@ -159,7 +162,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = {
},
en: {
personalization: {
saveFailed: 'Could not save', displayName: 'Display name', displayNameHelp: 'Maka uses this name when addressing you. Leave it blank to use “you”.', displayNamePlaceholder: 'For example: JK', interfaceLanguage: 'Interface language', interfaceLanguageHelp: 'Choose the language used by Maka. Changes apply immediately and persist after restart.', localeOptions: [['auto', 'Follow system'], ['zh', '中文'], ['en', 'English']], assistantTone: 'Assistant tone', assistantToneHelp: 'Up to 500 characters. This changes response style only; permission and safety rules still apply. Changes save automatically.', assistantTonePlaceholder: 'For example: technically rigorous, concise, and no emoji.',
saveFailed: 'Could not save', displayName: 'Display name', displayNameHelp: 'Maka uses this name when addressing you. Leave it blank to use “you”.', displayNamePlaceholder: 'For example: JK', displayNameUnset: 'Not set — Maka will say “you”', displayNameChange: 'Change', displayNameSet: 'Set', interfaceLanguage: 'Interface language', interfaceLanguageHelp: 'Choose the language used by Maka. Changes apply immediately and persist after restart.', localeOptions: [['auto', 'Follow system'], ['zh', '中文'], ['en', 'English']], assistantTone: 'Assistant tone', assistantToneHelp: 'Up to 500 characters. This changes response style only; permission and safety rules still apply. Changes save automatically.', assistantTonePlaceholder: 'For example: technically rigorous, concise, and no emoji.',
},
sections: {
identity: 'Identity', identityHelp: 'How Maka addresses you, plus interface language and response tone.',
Expand Down
56 changes: 46 additions & 10 deletions apps/desktop/src/renderer/settings/appearance-settings-page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ import {
VStack,
} from '@astryxdesign/core';
import { SettingsField, SettingsPage, SettingsRow, SettingsSection } from './settings-section';
import { SettingsExpandableRow } from './settings-expandable-row';
import { getSettingsSharedCopy } from '../locales/settings-shared-copy';
import type {
AppSettings,
PersonalizationSettings,
Expand DownExpand Up@@ -69,6 +71,9 @@ export function PersonalizationSettingsPage(props: {
const locale = useUiLocale();
const copy = getSettingsPreferencesCopy(locale).personalization;
const sections = getSettingsPreferencesCopy(locale).sections;
const sharedCopy = getSettingsSharedCopy(locale);
// At most one row in the group is open — the template's own rule.
const [expandedRow, setExpandedRow] = useState<string | null>(null);
// Persist the tone textarea this long after the user stops typing; blur
// flushes immediately regardless.
const TONE_AUTOSAVE_DEBOUNCE_MS = 800;
Expand DownExpand Up@@ -120,33 +125,36 @@ export function PersonalizationSettingsPage(props: {

// Shared persist path for every personalization field. Locale has its own
// last-write-wins lane so unrelated saves cannot steal rollback ownership.
async function persistPersonalization(patch: Partial<PersonalizationSettings>) {
// Returns whether the write landed. The autosaving fields ignore it — they
// have nowhere to put the answer — but a row that closes on save has to
// know, or a failed write collapses the row onto the old value and drops
// the draft with only a toast to show for it.
async function persistPersonalization(patch: Partial<PersonalizationSettings>): Promise<boolean> {
const ticket = ++persistTicketRef.current;
const localeTicket = patch.uiLocale === undefined ? null : ++localePersistTicketRef.current;
persistPendingCountRef.current += 1;
try {
const result = await props.onUpdate({ personalization: patch });
if (!personalizationMountedRef.current) return;
// Saved. An unmounted page just has nowhere left to reflect it.
if (!personalizationMountedRef.current) return true;
if (localeTicket !== null && localeTicket === localePersistTicketRef.current) {
setUiLocale(result.settings.personalization.uiLocale);
}
return true;
} catch (error) {
if (!personalizationMountedRef.current) return;
if (!personalizationMountedRef.current) return false;
if (localeTicket !== null && localeTicket === localePersistTicketRef.current) {
setUiLocale(value.uiLocale);
}
if (ticket === persistTicketRef.current) {
toast.error(copy.saveFailed, settingsActionErrorMessage(error, locale));
}
return false;
} finally {
persistPendingCountRef.current = Math.max(0, persistPendingCountRef.current - 1);
}
}

function flushDisplayName(nextValue: string) {
void persistPersonalization({ displayName: nextValue.trim().slice(0, 60) });
}

function persistLocale(next: UiLocalePreference) {
setUiLocale(next);
void persistPersonalization({ uiLocale: next });
Expand DownExpand Up@@ -177,18 +185,46 @@ export function PersonalizationSettingsPage(props: {
neighboring preferences; the full-width tone field uses the vertical
row variant. */}
<SettingsSection title={sections.identity} description={sections.identityHelp}>
<SettingsField>
{/* A name you set once and then read. A permanently-open input asked
the user to fill in something already filled in, and its blur-save
gave them no way to back out of a change. The row reports the
settled value and opens on demand; Cancel puts the draft back. */}
<SettingsExpandableRow
label={copy.displayName}
value={value.displayName || copy.displayNameUnset}
actionLabel={value.displayName ? copy.displayNameChange : copy.displayNameSet}
isEditing={expandedRow === 'displayName'}
canSave={displayName.trim() !== value.displayName}
saveLabel={sharedCopy.save}
cancelLabel={sharedCopy.cancel}
onEdit={() => {
setDisplayName(value.displayName);
setExpandedRow('displayName');
}}
onCancel={() => {
setDisplayName(value.displayName);
setExpandedRow(null);
}}
onSave={async () => {
// Only close on a write that landed: a failed save leaves the row
// open with the draft intact, which is the promise explicit saving
// makes and blur-autosave could not keep.
if (await persistPersonalization({ displayName: displayName.trim().slice(0, 60) })) {
setExpandedRow(null);
}
}}
>
<TextInput
type="text"
value={displayName}
onChange={(value) => setDisplayName(value.slice(0, 60))}
onBlur={() => flushDisplayName(displayName)}
placeholder={copy.displayNamePlaceholder}
label={copy.displayName}
description={copy.displayNameHelp}
isLabelHidden
width="100%"
/>
</SettingsField>
</SettingsExpandableRow>

{/*
PR-LANG-PREF-0 (WAWQAQ msg `edc9cb41` + kenji `7e532892`
Expand Down
129 changes: 129 additions & 0 deletions apps/desktop/src/renderer/settings/settings-expandable-row.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
import { useEffect, useRef, type ReactNode } from 'react';
import { Button, HStack, Text } from '@astryxdesign/core';
import { SettingsField, SettingsRow } from './settings-section';

/**
* A settled value that becomes a form only when the user asks it to.
*
* The shape is Astryx's own — the `settings-sidebar` template's ExpandableRow
* (`@astryxdesign/cli/templates/pages/settings-sidebar/page.tsx:140-193`): a
* row reports its label and current value with one affordance to change it,
* and swaps in place for the editor plus Save / Cancel. At most one row in a
* group is open at a time, which is the caller's `expandedRow` state.
*
* The shape is copied; the template's implementation is not, because it has
* three problems this component exists to not have:
*
* 1. The template's trigger is `<a href="#">` with `preventDefault`. That
* announces itself as a link, ignores Space, and points nowhere. Opening a
* form is a button.
*
* 2. The template has no focus management at all. Expanding moves focus into
* the editor here, and Save / Cancel return it to the trigger the user came
* from — otherwise collapsing drops focus to the top of the document and a
* keyboard user loses their place in the list.
*
* 3. The template's Cancel only closes; its fields edit live, so there is
* nothing to discard. Ours is a real discard: the caller reverts its draft
* in `onCancel`, which is what makes Cancel mean anything.
*
* Built on `SettingsRow` rather than a bare HStack so the collapsed row keeps
* the Item vocabulary the rest of the surface uses — the `settingsRowEnd`
* width ceiling and the container queries in rows.css apply here too. The
* expanded editor sits in a `SettingsField`, the same full-width block a
* permanently-open input would use.
*/
export function SettingsExpandableRow(props: {
label: string;
/** The settled value, shown while collapsed. */
value: ReactNode;
/** Label for the affordance that opens the editor (更改 / 设置 / 编辑). */
actionLabel: string;
isEditing: boolean;
isDisabled?: boolean;
/** Save stays disabled until the draft actually differs from the value. */
canSave?: boolean;
saveLabel: string;
cancelLabel: string;
onEdit(): void;
onCancel(): void;
onSave(): void | Promise<void>;
children: ReactNode;
}) {
const triggerRef = useRef<HTMLButtonElement>(null);
const editorRef = useRef<HTMLDivElement>(null);
// Only pull focus for a transition the user drove. Without this the row
// would grab focus on first paint whenever a caller mounts already-expanded.
const wasEditingRef = useRef(props.isEditing);

useEffect(() => {
const opened = props.isEditing && !wasEditingRef.current;
const closed = !props.isEditing && wasEditingRef.current;
wasEditingRef.current = props.isEditing;
if (opened) {
// The first control the editor renders, whatever the caller put there.
const first = editorRef.current?.querySelector<HTMLElement>(
'input, textarea, select, button, [contenteditable="true"], [tabindex]:not([tabindex="-1"])',
);
first?.focus();
return;
}
if (closed) triggerRef.current?.focus();
}, [props.isEditing]);

// The collapsed row carries no aria-expanded / aria-controls on purpose.
// Those describe a disclosure — a trigger that stays put while a region
// beside it opens — and this is a mode swap: the trigger unmounts when the
// editor replaces it, so aria-expanded could never reach true and
// aria-controls would name a node that does not exist while collapsed.
// Declaring a contract the DOM never honours is worse than declaring none;
// the focus move is what tells assistive tech the mode changed, and that
// part is measured rather than assumed.
if (!props.isEditing) {
return (
<SettingsRow
label={props.label}
description={props.value}
align="start"
end={(
<Button
ref={triggerRef}
variant="ghost"
size="sm"
isDisabled={props.isDisabled}
onClick={props.onEdit}
label={props.actionLabel}
/>
)}
/>
);
}

return (
<SettingsField>
<div ref={editorRef} className="settingsExpandableEditor">
{/* The label survives the swap. Collapsed, the row's own label names
the value; expanded, the editor would otherwise be an unlabelled
box — the user pressed 更改 on something and must still be able to
see what. The caller hides the control's own label instead, so the
name is stated once. */}
<Text type="body" weight="semibold">{props.label}</Text>
{props.children}
<HStack gap={2}>
<Button
variant="primary"
isDisabled={props.isDisabled || props.canSave === false}
clickAction={() => props.onSave()}
label={props.saveLabel}
/>
<Button
variant="ghost"
isDisabled={props.isDisabled}
onClick={props.onCancel}
label={props.cancelLabel}
/>
</HStack>
</div>
</SettingsField>
);
}
9 changes: 9 additions & 0 deletions apps/desktop/src/renderer/styles/settings/rows.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -141,3 +141,12 @@
min-height: 2rem;
}
}

/* The expanded half of SettingsExpandableRow: the editor the caller supplies,
then its Save / Cancel pair. A grid rather than margins so the gap is the
row group's own rhythm and the editor keeps the field block's full width. */
.settingsExpandableEditor {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--space-3);
}
Loading