1. 修复登录界面文字被遮挡问题
2. 大字模式关闭后显示异常问题
3. 重新调整大字模式是否开启时的字体显示效果
This commit is contained in:
yangshilin
2026-04-10 20:35:57 +08:00
parent abf8497c2e
commit 17b9fa3466
27 changed files with 390 additions and 161 deletions

View File

@@ -88,7 +88,11 @@ def _is_emotion_heavy(text: str) -> bool:
return False
if any(m in t for m in _EMOTION_MARKERS):
return True
if len(t) >= 40 and ("" in t or "!" in t) and (".." in t or "" in t or "" in t):
if (
len(t) >= 40
and ("" in t or "!" in t)
and (".." in t or "" in t or "" in t)
):
return True
return False

View File

@@ -366,7 +366,11 @@ def get_guided_conversation_prompt(
current_stage, empty_slots
)
_prefix = f"{turn_directive_block.rstrip()}\n\n" if (turn_directive_block or "").strip() else ""
_prefix = (
f"{turn_directive_block.rstrip()}\n\n"
if (turn_directive_block or "").strip()
else ""
)
return f"""{_prefix}你是「岁月知己」——**主持式知己**:语气像最懂我的老朋友,**职责是帮用户把人生故事口述清楚**。{tone_line}

View File

@@ -124,12 +124,8 @@ def _run_batch_phase1_prep_chunk_with_bisect(
mid = len(segments) // 2
if mid < 1:
raise
left = _run_batch_phase1_prep_chunk_with_bisect(
segments[:mid], state, llm
)
right = _run_batch_phase1_prep_chunk_with_bisect(
segments[mid:], state, llm
)
left = _run_batch_phase1_prep_chunk_with_bisect(segments[:mid], state, llm)
right = _run_batch_phase1_prep_chunk_with_bisect(segments[mid:], state, llm)
merged = {**left, **right}
expected = {str(s.id) for s in segments}
if merged.keys() != expected:
@@ -181,7 +177,5 @@ def run_batch_phase1_prep_chunked(
missing,
extra,
)
raise ValueError(
"batch phase1 chunked: merged segment ids do not match input"
)
raise ValueError("batch phase1 chunked: merged segment ids do not match input")
return merged

View File

@@ -83,7 +83,9 @@ def _merge_phase2_list(
return list(by_tid.values())
def _fanout_list_merge_key(items: list[dict], patch_items: list[dict], id_key: str) -> None:
def _fanout_list_merge_key(
items: list[dict], patch_items: list[dict], id_key: str
) -> None:
by_id: dict[str, dict[str, Any]] = {}
for x in items:
k = str(x.get(id_key) or "").strip()
@@ -104,9 +106,11 @@ def _fanout_list_merge_key(items: list[dict], patch_items: list[dict], id_key: s
def _merge_fanout(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
out = dict(base)
for k, v in patch.items():
if k in ("story_images", "recompose_chapters", "memory_enrichment") and isinstance(
v, list
):
if k in (
"story_images",
"recompose_chapters",
"memory_enrichment",
) and isinstance(v, list):
id_key = (
"story_id"
if k == "story_images"
@@ -132,14 +136,14 @@ def _merge_doc(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
if k == "phase2" and isinstance(v, list):
out["phase2"] = _merge_phase2_list(list(out.get("phase2") or []), v)
elif k == "fanout" and isinstance(v, dict):
out["fanout"] = _merge_fanout(
dict(out.get("fanout") or _empty_fanout()), v
)
out["fanout"] = _merge_fanout(dict(out.get("fanout") or _empty_fanout()), v)
elif k == "phase1" and isinstance(v, dict):
cur = dict(out.get("phase1") or {})
for pk, pv in v.items():
if pk == "detail" and isinstance(pv, dict) and isinstance(
cur.get("detail"), dict
if (
pk == "detail"
and isinstance(pv, dict)
and isinstance(cur.get("detail"), dict)
):
cur["detail"] = {**cur["detail"], **pv}
else:

View File

@@ -712,7 +712,9 @@ class EvalJudgeManualService:
def _nonempty_chapters(cols: list[Any]) -> int:
return sum(
1 for x in cols if (getattr(x, "canonical_markdown", None) or "").strip()
1
for x in cols
if (getattr(x, "canonical_markdown", None) or "").strip()
)
conc = max(1, min(32, int(settings.eval_judge_memoir_chapter_concurrency)))
@@ -849,9 +851,7 @@ class EvalJudgeManualService:
judged = await asyncio.gather(*[_judge_one(p) for p in prepared])
judged.sort(
key=lambda r: (
r["order_index"]
if r["order_index"] is not None
else 10**9,
r["order_index"] if r["order_index"] is not None else 10**9,
r["enum_idx"],
)
)
@@ -900,7 +900,11 @@ class EvalJudgeManualService:
"""Streaming SSE: one event per chapter judge result, concurrent LLM calls."""
uid = (user_id or "").strip()
if not uid:
yield {"event": "error", "phase": "validate", "message": "user_id is required"}
yield {
"event": "error",
"phase": "validate",
"message": "user_id is required",
}
return
judge, resolved_model = _make_eval_judge(judge_provider, judge_model)
@@ -912,7 +916,10 @@ class EvalJudgeManualService:
trace_svc = EvalTraceService(self._db)
def _chapter_evidence_notes(
lineage_tier: str, evidence_summary: str, truncated: bool, dropped: list[str]
lineage_tier: str,
evidence_summary: str,
truncated: bool,
dropped: list[str],
) -> str:
drops = ",".join(dropped[:12]) if dropped else ""
return (
@@ -926,8 +933,14 @@ class EvalJudgeManualService:
uid, self._db, active_only=True, is_new_only=None
)
except Exception as e:
logger.exception("manual memoir stream: chapter list failed user_id={}", uid)
yield {"event": "error", "phase": "load", "message": f"加载章节列表失败:{e}"}
logger.exception(
"manual memoir stream: chapter list failed user_id={}", uid
)
yield {
"event": "error",
"phase": "load",
"message": f"加载章节列表失败:{e}",
}
return
yield {
@@ -957,15 +970,22 @@ class EvalJudgeManualService:
cb = await trace_svc.build_chapter_bundle(uid, ch)
formatted, cb2 = await trace_svc.format_chapter_bundle(cb)
fm = formatted.format_meta
prepared.append({
"ch": ch, "bl": bl, "md": md,
prepared.append(
{
"ch": ch,
"bl": bl,
"md": md,
"baseline_excerpt": baseline_excerpt,
"formatted": formatted, "cb2": cb2, "fm": fm,
})
"formatted": formatted,
"cb2": cb2,
"fm": fm,
}
)
except Exception as e:
logger.exception(
"manual memoir stream: chapter prepare failed user_id={} chapter_id={}",
uid, ch.id,
uid,
ch.id,
)
yield {
"event": "chapter_error",
@@ -1025,7 +1045,8 @@ class EvalJudgeManualService:
except Exception as exc:
logger.warning(
"memoir stream: baseline judge failed ch={} err={}",
ch.id, exc,
ch.id,
exc,
)
baseline_error = str(exc)
@@ -1040,14 +1061,17 @@ class EvalJudgeManualService:
except Exception as e:
logger.exception(
"manual memoir stream: chapter judge failed user_id={} chapter_id={}",
uid, ch.id,
uid,
ch.id,
)
await result_queue.put({
await result_queue.put(
{
"event": "chapter_error",
"chapter_id": ch.id,
"title": ch.title,
"message": f"评审失败:{e}",
})
}
)
return
cj = cj_res.output
compare_summary = build_memoir_compare_summary(
@@ -1073,12 +1097,14 @@ class EvalJudgeManualService:
row["judge_error"] = cj_res.error
if not cj and not cj_res.error:
row["judge_error"] = "empty_output"
await result_queue.put({
await result_queue.put(
{
"event": "chapter_judge",
"index": idx,
"chapter": row,
"ok": cj is not None,
})
}
)
tasks = [asyncio.create_task(_judge_one(i, p)) for i, p in enumerate(prepared)]

View File

@@ -229,8 +229,12 @@ class MemoirJudgeOutput(BaseModel):
# 细项:校验放宽到 0100真实满分仍以 rubric 为准,由 after 钳制
mem_fidelity: float = Field(default=0, ge=0, le=100, description="记忆忠实度")
mem_factual_accuracy: float = Field(default=0, ge=0, le=100, description="事实准确性")
mem_factual_coverage: float = Field(default=0, ge=0, le=100, description="事实覆盖率")
mem_factual_accuracy: float = Field(
default=0, ge=0, le=100, description="事实准确性"
)
mem_factual_coverage: float = Field(
default=0, ge=0, le=100, description="事实覆盖率"
)
mem_traceability: float = Field(default=0, ge=0, le=100, description="记忆可追溯性")
info_slot_coverage: float = Field(default=0, ge=0, le=100, description="槽位覆盖度")
@@ -290,7 +294,9 @@ class MemoirJudgeOutput(BaseModel):
def _coerce_memoir_judge_input(cls, data: Any) -> Any:
if not isinstance(data, dict):
return data
data["rationale"] = "" if data.get("rationale") is None else str(data["rationale"])
data["rationale"] = (
"" if data.get("rationale") is None else str(data["rationale"])
)
for key in ("major_strengths", "major_issues", "insufficient_evidence"):
data[key] = _coerce_judge_str_list(data.get(key))
raw_refs = data.get("evidence_refs")

View File

@@ -492,7 +492,9 @@ class EvalJudgeService:
self._llm,
prompt,
MemoirJudgeOutput,
max_tokens=max(512, int(settings.eval_judge_memoir_completion_max_tokens)),
max_tokens=max(
512, int(settings.eval_judge_memoir_completion_max_tokens)
),
agent="EvalJudgeService.judge_memoir",
)
return JudgeCallResult(output=out)

View File

@@ -106,9 +106,7 @@ def build_memoir_compare_summary(
key_regressions = [
v["label"] for v in leaf_deltas.values() if float(v["delta"]) <= -0.5
]
key_gains = [
v["label"] for v in leaf_deltas.values() if float(v["delta"]) >= 0.5
]
key_gains = [v["label"] for v in leaf_deltas.values() if float(v["delta"]) >= 0.5]
parity_passed = total_delta >= -2.0 and len(key_regressions) <= 3
surpass_passed = total_delta >= 2.0 and len(key_regressions) <= 1

View File

@@ -363,9 +363,7 @@ def ingest_transcripts_batch_sync(
if not text:
continue
primary_mid = (
primary_user_message_id_from_lineage(lineage_json)
if lineage_json
else None
primary_user_message_id_from_lineage(lineage_json) if lineage_json else None
)
source = create_source_sync(
session,
@@ -419,9 +417,7 @@ def ingest_transcripts_batch_sync(
)
emb_ok = (
embedding_provider.is_available()
if embedding_provider is not None
else False
embedding_provider.is_available() if embedding_provider is not None else False
)
logger.info(
"event=memory_ingest_batch_done user_id={} sources={} chunks={} "

View File

@@ -128,7 +128,9 @@ def _log_task_prerun(
@task_success.connect
def _log_task_success(sender: object | None = None, result: object | None = None, **_: object) -> None:
def _log_task_success(
sender: object | None = None, result: object | None = None, **_: object
) -> None:
"""仅成功路径;失败见 ``task_failure``(避免 ``task_postrun`` 在异常态仍触发)。"""
name = getattr(sender, "name", None) if sender is not None else None
name = name or "?"

View File

@@ -185,7 +185,9 @@ def _update_task_status_sync(
logger.debug("任务状态已更新: task_id={} status={}", task_id, status)
except Exception as e:
logger.error("event=memoir_task_status_update_failed msg=更新任务状态失败 exc={}", e)
logger.error(
"event=memoir_task_status_update_failed msg=更新任务状态失败 exc={}", e
)
def _merge_chapter_image_assets(

View File

@@ -151,9 +151,7 @@ def _claim_story_image_intent_sync(db, story_id: str, claim_token: str):
@shared_task(bind=True, max_retries=3, default_retry_delay=30)
def generate_story_image(
self, story_id: str, memoir_correlation_id: str | None = None
):
def generate_story_image(self, story_id: str, memoir_correlation_id: str | None = None):
"""
为 story 生成主插图。
从 story_image_intents 原子认领 primary intent生成后写入 assets 并更新 intent。

View File

@@ -47,9 +47,7 @@ def test_run_batch_phase1_prep_chunked_splits_95_into_four_calls(
"app.agents.memoir.batch_phase1_prep.run_batch_phase1_prep",
fake_prep,
)
segments = [
SimpleNamespace(id=f"s{i}", user_input_text="hello") for i in range(95)
]
segments = [SimpleNamespace(id=f"s{i}", user_input_text="hello") for i in range(95)]
by_id = run_batch_phase1_prep_chunked(
segments,
_state(),
@@ -85,9 +83,7 @@ def test_chunked_bisect_on_value_error(monkeypatch: pytest.MonkeyPatch) -> None:
"app.agents.memoir.batch_phase1_prep.run_batch_phase1_prep",
fake_prep,
)
segments = [
SimpleNamespace(id=f"b{i}", user_input_text="x") for i in range(4)
]
segments = [SimpleNamespace(id=f"b{i}", user_input_text="x") for i in range(4)]
by_id = run_batch_phase1_prep_chunked(
segments,
_state(),

View File

@@ -83,23 +83,15 @@ def test_merge_fanout_lists_merge_by_id(fake_redis: _FakeRedis) -> None:
def test_init_and_index_resolve(fake_redis: _FakeRedis) -> None:
mpp.init_pipeline_run_from_phase1(
"user-a", "cid-4", "p1tid", segment_count=3
)
mpp.init_pipeline_run_from_phase1("user-a", "cid-4", "p1tid", segment_count=3)
cid = mpp.resolve_correlation_id_for_phase1_task("p1tid")
assert cid == "cid-4"
snap = mpp.get_pipeline_run_for_eval(
"user-a", phase1_task_id="p1tid"
)
snap = mpp.get_pipeline_run_for_eval("user-a", phase1_task_id="p1tid")
assert snap is not None
assert snap["user_id"] == "user-a"
assert snap["phase1"]["task_id"] == "p1tid"
def test_get_pipeline_run_for_eval_user_mismatch(fake_redis: _FakeRedis) -> None:
mpp.init_pipeline_run_from_phase1(
"user-a", "cid-5", "p1b", segment_count=1
)
assert (
mpp.get_pipeline_run_for_eval("other", phase1_task_id="p1b") is None
)
mpp.init_pipeline_run_from_phase1("user-a", "cid-5", "p1b", segment_count=1)
assert mpp.get_pipeline_run_for_eval("other", phase1_task_id="p1b") is None

View File

@@ -26,7 +26,9 @@ def test_enrichment_payload_roundtrip() -> None:
assert p.facts[0].subject == "王伟"
def test_enrich_memory_after_ingest_sync_single_llm_call(monkeypatch: pytest.MonkeyPatch) -> None:
def test_enrich_memory_after_ingest_sync_single_llm_call(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from app.features.memory import enrichment as mod
monkeypatch.setattr("app.core.config.settings.memory_enrichment_enabled", True)
@@ -81,7 +83,9 @@ def test_enrich_memory_after_ingest_sync_single_llm_call(monkeypatch: pytest.Mon
assert facts[0]["status"] == "confirmed"
def test_enrich_memory_skips_when_parse_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
def test_enrich_memory_skips_when_parse_returns_none(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from app.features.memory import enrichment as mod
monkeypatch.setattr("app.core.config.settings.memory_enrichment_enabled", True)

View File

@@ -46,7 +46,9 @@ def test_retrieve_evidence_bundle_sync_uses_vector_search(
}
monkeypatch.setattr(evidence_mod, "search_chunks_vector_sync", fake_search)
monkeypatch.setattr(evidence_mod, "fetch_evidence_metadata_parallel_sync", fake_meta)
monkeypatch.setattr(
evidence_mod, "fetch_evidence_metadata_parallel_sync", fake_meta
)
out = retrieve_evidence_bundle_sync(
session=object(),

View File

@@ -200,6 +200,26 @@
"lineHeightTight": 24,
"lineHeightLoose": 29,
"lineHeightXLoose": 34
},
"xlarge": {
"headingLarge": 44,
"headingMedium": 33,
"headingSmall": 28,
"titleLarge": 28,
"titleMedium": 25,
"titleSmall": 21,
"bodyLarge": 28,
"bodyMedium": 26,
"bodySmall": 18,
"captionLarge": 18,
"captionMedium": 16,
"captionSmall": 15,
"sectionTitle": 16,
"badge": 13,
"lineHeightNormal": 30,
"lineHeightTight": 28,
"lineHeightLoose": 34,
"lineHeightXLoose": 40
}
}
}

View File

@@ -960,13 +960,14 @@ export default function ConversationScreen() {
/** 大字模式:朗读图标与触控区与气泡字号同档放大 */
const chatReadAloudIconSize = largeText ? 24 : 20;
const chatReadAloudButtonSize = largeText ? 52 : 44;
const chatVoiceDurationStyle = useMemo(
() => ({
fontSize: largeText ? typography.headingSmall : typography.titleMedium,
const chatVoiceDurationStyle = useMemo(() => {
const fs = largeText ? typography.headingSmall : typography.titleMedium;
return {
fontSize: fs,
lineHeight: Math.ceil(fs * 1.25),
fontWeight: '500' as const,
}),
[typography, largeText],
);
};
}, [typography, largeText]);
const chatTypingLabelStyle = useMemo(
() => ({
fontSize: largeText ? typography.bodySmall : typography.captionLarge,
@@ -977,6 +978,9 @@ export default function ConversationScreen() {
const headerTitleFontStyle = useMemo(
() => ({
fontSize: largeText ? typography.headingMedium : typography.headingSmall,
lineHeight: largeText
? Math.ceil(typography.headingMedium * 1.4)
: Math.ceil(typography.headingSmall * 1.28),
}),
[typography, largeText],
);
@@ -992,13 +996,14 @@ export default function ConversationScreen() {
}),
[typography, inputLineHeight, largeText],
);
const connectionNoticeTitleStyle = useMemo(
() => ({
fontSize: largeText ? typography.titleSmall : typography.captionLarge,
const connectionNoticeTitleStyle = useMemo(() => {
const fs = largeText ? typography.titleSmall : typography.captionLarge;
return {
fontSize: fs,
lineHeight: Math.ceil(fs * 1.28),
fontWeight: '700' as const,
}),
[typography, largeText],
);
};
}, [typography, largeText]);
const connectionNoticeBodyStyle = useMemo(
() => ({
fontSize: largeText ? typography.bodySmall : typography.captionLarge,
@@ -1401,7 +1406,10 @@ export default function ConversationScreen() {
variant="chat"
title={
<View style={styles.headerTitleBlock}>
<Text style={[styles.headerTitle, headerTitleFontStyle]}>
<Text
style={[styles.headerTitle, headerTitleFontStyle]}
includeFontPadding={Platform.OS === 'android' ? false : undefined}
>
{tApp('name')}
</Text>
{showConnectionBadge ? (
@@ -1757,6 +1765,7 @@ const styles = StyleSheet.create({
},
voiceDurationText: {
fontSize: 18,
lineHeight: 24,
fontWeight: '500',
},
voiceDurationTextUser: {
@@ -1879,15 +1888,13 @@ const styles = StyleSheet.create({
},
voiceRecordDurationWrap: {
minWidth: 40,
height: 20,
minHeight: 20,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 1,
},
/** 字号/行高由 voiceRecordDurationStyle 提供,避免大字模式仍锁 20px 高裁切 */
voiceRecordDuration: {
fontSize: 12,
/** 与容器等高,避免 Android/iOS 数字相对胶囊上下偏移 */
height: 20,
lineHeight: 20,
paddingVertical: 0,
marginVertical: 0,
color: 'rgba(27, 27, 31, 0.86)',
@@ -1905,7 +1912,7 @@ const styles = StyleSheet.create({
width: '100%',
},
sendButton: {
height: 44,
minHeight: 44,
paddingHorizontal: 16,
paddingVertical: 11,
borderRadius: 22,

View File

@@ -6,6 +6,7 @@ import { useColorScheme } from '@/hooks/use-color-scheme';
import { useTranslation } from 'react-i18next';
import { TabBarIcon } from '@/components/tab-bar-icon';
import { useTypography } from '@/core/typography-context';
import { useSession } from '@/features/auth/hooks';
// Life-Echo bottom nav colors (from HTML reference)
@@ -27,6 +28,7 @@ const TAB_BAR_PADDING_HORIZONTAL = 12;
export default function TabsLayout() {
const { status } = useSession();
const typography = useTypography();
const { colorScheme } = useColorScheme();
const insets = useSafeAreaInsets();
const isDark = colorScheme === 'dark';
@@ -75,7 +77,8 @@ export default function TabsLayout() {
marginBottom: 0,
},
tabBarLabelStyle: {
fontSize: 11,
fontSize: typography.captionLarge,
lineHeight: typography.lineHeightTight,
fontWeight: '600',
textTransform: 'uppercase',
marginTop: 4,

View File

@@ -154,14 +154,16 @@ function ConversationCard({
{item.title}
</Text>
<Text
className="shrink-0 text-sm font-semibold uppercase tracking-wider text-muted-foreground"
variant="captionMedium"
className="shrink-0 font-semibold uppercase tracking-wider text-muted-foreground"
style={{ fontVariant: ['tabular-nums'] }}
>
{formatRelativeConversationListTime(item.latestMessageTime, t)}
</Text>
</View>
<Text
className="min-w-0 self-stretch text-base font-semibold text-muted-foreground"
variant="bodyMedium"
className="min-w-0 self-stretch font-semibold text-muted-foreground"
numberOfLines={1}
ellipsizeMode="tail"
>
@@ -202,7 +204,8 @@ function SwipeableConversationCard({
>
<Icon as={Trash2} className="text-destructive-foreground" size={24} />
<Text
className="mt-1 text-xs font-semibold text-destructive-foreground"
variant="captionSmall"
className="mt-1 font-semibold text-destructive-foreground"
selectable={false}
>
{t('delete')}
@@ -405,11 +408,13 @@ export default function ConversationsScreen() {
<Text
variant="h2"
className="text-center font-display text-primary"
style={{ borderWidth: 0, fontSize: 28, lineHeight: 38 }}
>
{t('greetingTitle')}
</Text>
<Text className="text-center text-base font-medium leading-6 text-muted-foreground">
<Text
variant="bodyLarge"
className="text-center font-medium text-muted-foreground"
>
{t('emptyGreetingSubtitle')}
</Text>
</View>
@@ -429,13 +434,15 @@ export default function ConversationsScreen() {
<Text
variant="h2"
className="text-center font-display text-primary"
style={{ borderWidth: 0, fontSize: 28, lineHeight: 38 }}
>
{showResumeEntry
? t('resumeChatTitle')
: t('greetingTitle')}
</Text>
<Text className="text-center text-base font-medium leading-6 text-muted-foreground">
<Text
variant="bodyLarge"
className="text-center font-medium text-muted-foreground"
>
{showResumeEntry
? t('resumeChatSubtitle')
: t('emptyGreetingSubtitle')}
@@ -449,10 +456,14 @@ export default function ConversationsScreen() {
{t('recentChats')}
</Text>
<Pressable
className="min-h-11 min-w-11 items-center justify-center active:opacity-70"
className="min-h-11 items-center justify-center px-2 active:opacity-70"
hitSlop={{ top: 6, bottom: 6, left: 4, right: 4 }}
onPress={() => {}}
>
<Text className="text-sm font-semibold text-primary">
<Text
variant="bodySmall"
className="font-semibold text-primary"
>
{t('viewAll')}
</Text>
</Pressable>

View File

@@ -23,6 +23,7 @@ import { Icon } from '@/components/ui/icon';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
import { ScreenGutter } from '@/constants/layout';
import { useTypography } from '@/core/typography-context';
import { useCreateConversation } from '@/features/conversation/hooks';
import {
buildFrameworkChapterPlaceholders,
@@ -77,9 +78,18 @@ function ChapterCard({
onReadPress: () => void;
onContinuePress: () => void;
}) {
const typography = useTypography();
const { width } = useWindowDimensions();
const contentWidth = Math.min(width - ScreenGutter * 2, 672);
const gutter = ScreenGutter;
const completedTitleLineHeight = Math.max(
typography.lineHeightTight + 6,
typography.headingMedium + 6,
);
const draftingTitleLineHeight = Math.max(
typography.lineHeightTight + 4,
typography.titleLarge + 4,
);
const chapterIndex = item.orderIndex + 1;
const chapterLabel = t('chapterLabel').replace(
@@ -142,7 +152,8 @@ function ChapterCard({
<View style={{ padding: gutter, gap: 12 }}>
<View style={{ gap: 4, minHeight: 72 }}>
<Text
className="text-xs font-medium text-muted-foreground"
variant="captionMedium"
className="font-medium text-muted-foreground"
selectable
>
{chapterLabel}
@@ -150,7 +161,7 @@ function ChapterCard({
<Text
variant="h2"
className="text-foreground"
style={{ lineHeight: 34 }}
style={{ lineHeight: completedTitleLineHeight }}
numberOfLines={2}
selectable
>
@@ -161,7 +172,8 @@ function ChapterCard({
<View className="flex-row items-center gap-2">
<Icon as={FileText} className="text-muted-foreground" size={16} />
<Text
className="text-sm font-medium text-muted-foreground"
variant="bodySmall"
className="font-medium text-muted-foreground"
style={{ fontVariant: ['tabular-nums'] }}
selectable
>
@@ -174,7 +186,10 @@ function ChapterCard({
style={{ borderCurve: 'continuous' }}
onPress={onReadPress}
>
<Text className="text-base font-semibold text-primary-foreground">
<Text
variant="bodyLarge"
className="font-semibold text-primary-foreground"
>
{t('readMemory')}
</Text>
</Pressable>
@@ -197,7 +212,8 @@ function ChapterCard({
<View className="gap-1">
<View>
<Text
className="text-xs font-medium text-muted-foreground"
variant="captionMedium"
className="font-medium text-muted-foreground"
selectable
>
{chapterLabel}
@@ -205,7 +221,7 @@ function ChapterCard({
<Text
variant="h4"
className="mt-0.5 text-foreground"
style={{ lineHeight: 28 }}
style={{ lineHeight: draftingTitleLineHeight }}
numberOfLines={2}
selectable
>
@@ -213,7 +229,11 @@ function ChapterCard({
</Text>
</View>
<View className="mt-2 flex-row items-center gap-2">
<Text className="text-sm font-medium text-secondary" selectable>
<Text
variant="bodySmall"
className="font-medium text-secondary"
selectable
>
{t('statusDrafting')}
</Text>
</View>
@@ -223,7 +243,7 @@ function ChapterCard({
style={{ borderCurve: 'continuous' }}
onPress={onContinuePress}
>
<Text className="text-base font-semibold text-secondary">
<Text variant="bodyLarge" className="font-semibold text-secondary">
{t('continueWriting')}
</Text>
</Pressable>
@@ -237,7 +257,7 @@ function MemoirLoadError({ onRetry }: { onRetry: () => void }) {
const { t } = useTranslation('memoir');
return (
<View className="items-center gap-4 rounded-2xl border border-dashed border-border bg-muted/20 p-10">
<Text className="text-center text-base text-destructive">
<Text variant="bodyLarge" className="text-center text-destructive">
{t('loadErrorMessage')}
</Text>
<Pressable
@@ -245,7 +265,10 @@ function MemoirLoadError({ onRetry }: { onRetry: () => void }) {
style={{ borderCurve: 'continuous' }}
onPress={onRetry}
>
<Text className="font-semibold text-primary-foreground">
<Text
variant="bodyMedium"
className="font-semibold text-primary-foreground"
>
{t('loadErrorRetry')}
</Text>
</Pressable>

View File

@@ -90,11 +90,15 @@ function LanguageRow({
<Icon as={Globe} className="text-muted-foreground" size={20} />
<View>
<Text className="font-medium text-foreground">{label}</Text>
<Text className="text-sm text-muted-foreground">{description}</Text>
<Text variant="bodySmall" className="text-muted-foreground">
{description}
</Text>
</View>
</View>
<View className="flex-row items-center gap-2">
<Text className="text-sm text-muted-foreground">{currentLabel}</Text>
<Text variant="bodySmall" className="text-muted-foreground">
{currentLabel}
</Text>
<Icon as={ChevronRight} className="text-muted-foreground" size={20} />
</View>
</Pressable>
@@ -120,7 +124,9 @@ function SettingRow({
<Icon as={SettingIcon} className="text-muted-foreground" size={20} />
<View>
<Text className="font-medium text-foreground">{label}</Text>
<Text className="text-sm text-muted-foreground">{description}</Text>
<Text variant="bodySmall" className="text-muted-foreground">
{description}
</Text>
</View>
</View>
<Switch checked={value} onCheckedChange={onValueChange} />
@@ -203,7 +209,7 @@ export default function ProfileScreen() {
<Text variant="h3" className="text-foreground">
{user?.nickname ?? t('userNamePlaceholder')}
</Text>
<Text className="text-sm text-muted-foreground">
<Text variant="bodySmall" className="text-muted-foreground">
{t('userTier', { tier: tierLabel })}
</Text>
</View>

View File

@@ -1,12 +1,20 @@
import { router } from 'expo-router';
import { ArrowLeft } from 'lucide-react-native';
import React from 'react';
import { Pressable, View } from 'react-native';
import { Platform, Pressable, Text as RNText, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Icon } from '@/components/ui/icon';
import { Text } from '@/components/ui/text';
import { useTypography } from '@/core/typography-context';
import { ScreenGutter } from '@/constants/layout';
import { useAppSettings } from '@/hooks/use-app-settings';
/** 默认最小触控目标 48dp大字模式下与标题字号匹配略向左扩展便于够到边缘 */
const BACK_HIT_MIN = 48;
const BACK_HIT_MIN_LARGE = 56;
const BACK_ICON_SIZE = 24;
const BACK_ICON_SIZE_LARGE = 30;
const BACK_EXTRA_HIT_LEFT = 4;
export type ScreenHeaderVariant = 'default' | 'chat' | 'reading';
@@ -64,21 +72,44 @@ export function ScreenHeader({
useSafeArea = true,
}: ScreenHeaderProps) {
const insets = useSafeAreaInsets();
const { largeText } = useAppSettings();
const typography = useTypography();
const colors = VARIANT_COLORS[variant];
const backTouchMin = largeText ? BACK_HIT_MIN_LARGE : BACK_HIT_MIN;
const backIconSize = largeText ? BACK_ICON_SIZE_LARGE : BACK_ICON_SIZE;
const handleBack = onBack ?? (() => router.back());
const bgColor = backgroundColor ?? colors.background;
const titleColor = colors.title;
const iconColor = colors.icon ?? colors.iconSecondary;
/**
* 不要用外层 minHeight「框死」整块栏paddingTop 含安全区时会把内容区压扁。
* 标题行最小高度随当前排版 token 计算(关大字也要留够,避免裁字 / Android font padding
*/
const barPaddingBottom = largeText ? 18 : 16;
const titleRowPaddingV = largeText ? 8 : 4;
const titleFontSize =
variant === 'chat'
? largeText
? typography.headingMedium
: typography.headingSmall
: Math.max(typography.titleLarge, typography.headingSmall);
const titleLineMin =
variant === 'chat'
? Math.ceil(titleFontSize * (largeText ? 1.45 : 1.38)) +
(largeText ? 14 : 10)
: Math.ceil(titleFontSize * 1.32) + (largeText ? 10 : 8);
const titleRowMinHeight = Math.max(backTouchMin, titleLineMin);
const containerStyle = {
flexDirection: 'row' as const,
alignItems: 'center' as const,
justifyContent: 'space-between' as const,
paddingHorizontal: Math.max(ScreenGutter, 16),
paddingTop: useSafeArea ? Math.max(insets.top, 12) : 12,
paddingBottom: 16,
minHeight: 56,
paddingBottom: barPaddingBottom,
...(bgColor && { backgroundColor: bgColor }),
...(absolute && {
position: 'absolute' as const,
@@ -105,41 +136,74 @@ export function ScreenHeader({
gap: 12,
flex: 1,
minWidth: 0,
paddingVertical: titleRowPaddingV,
minHeight: titleRowMinHeight,
}}
>
{showBack && (
<Pressable
onPress={handleBack}
hitSlop={
Platform.OS === 'android'
? {
top: largeText ? 10 : 6,
bottom: largeText ? 10 : 6,
right: 8,
left: BACK_EXTRA_HIT_LEFT,
}
: {
top: largeText ? 8 : 4,
bottom: largeText ? 8 : 4,
left: 2,
right: 4,
}
}
android_ripple={
Platform.OS === 'android'
? { borderless: true, radius: backTouchMin / 2 }
: undefined
}
style={({ pressed }) => ({
padding: 8,
minWidth: backTouchMin,
minHeight: backTouchMin,
marginLeft: -BACK_EXTRA_HIT_LEFT,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 9999,
opacity: pressed ? 0.7 : 1,
opacity: Platform.OS === 'android' ? 1 : pressed ? 0.7 : 1,
})}
accessibilityRole="button"
accessibilityLabel={backAccessibilityLabel}
>
{iconColor ? (
<Icon as={ArrowLeft} size={24} color={iconColor} />
<Icon as={ArrowLeft} size={backIconSize} color={iconColor} />
) : (
<Icon as={ArrowLeft} size={24} className="text-foreground" />
<Icon
as={ArrowLeft}
size={backIconSize}
className="text-foreground"
/>
)}
</Pressable>
)}
{typeof title === 'string' ? (
<Text
<RNText
numberOfLines={1}
selectable
style={{
flex: 1,
fontSize: variant === 'chat' ? 24 : 18,
fontSize: variant === 'chat' ? titleFontSize : 18,
lineHeight:
variant === 'chat'
? Math.ceil(titleFontSize * 1.35)
: Math.ceil(18 * 1.32),
fontWeight: '700',
letterSpacing: -0.5,
...(titleColor && { color: titleColor }),
}}
className={!titleColor ? 'text-foreground' : undefined}
>
{title}
</Text>
</RNText>
) : (
<View style={{ flex: 1, minWidth: 0 }}>{title}</View>
)}

View File

@@ -1,6 +1,7 @@
import { buttonTextVariants, buttonVariants } from '@/components/ui/button';
import { NativeOnlyAnimatedView } from '@/components/ui/native-only-animated-view';
import { TextClassContext } from '@/components/ui/text';
import { useTypography } from '@/core/typography-context';
import { cn } from '@/lib/utils';
import * as AlertDialogPrimitive from '@rn-primitives/alert-dialog';
import * as React from 'react';
@@ -97,11 +98,20 @@ function AlertDialogFooter({ className, ...props }: ViewProps) {
function AlertDialogTitle({
className,
style,
...props
}: AlertDialogPrimitive.TitleProps & React.RefAttributes<AlertDialogPrimitive.TitleRef>) {
const typography = useTypography();
return (
<AlertDialogPrimitive.Title
className={cn('min-w-0 text-foreground text-lg font-semibold', className)}
className={cn('min-w-0 text-foreground font-semibold', className)}
style={[
{
fontSize: typography.titleLarge,
lineHeight: typography.lineHeightTight,
},
style,
]}
{...props}
/>
);
@@ -113,15 +123,20 @@ function AlertDialogDescription({
...props
}: AlertDialogPrimitive.DescriptionProps &
React.RefAttributes<AlertDialogPrimitive.DescriptionRef>) {
const typography = useTypography();
return (
<AlertDialogPrimitive.Description
className={cn(
// shrink: RN 中 Text 在 flex 内需 flexShrink 才能按父宽换行
'w-full min-w-0 shrink text-muted-foreground text-sm',
'w-full min-w-0 shrink text-muted-foreground',
className
)}
style={[
Platform.OS === 'web' ? undefined : { flexShrink: 1 },
{
fontSize: typography.bodyMedium,
lineHeight: typography.lineHeightLoose,
},
style,
]}
{...props}
@@ -135,7 +150,15 @@ function AlertDialogAction({
}: AlertDialogPrimitive.ActionProps & React.RefAttributes<AlertDialogPrimitive.ActionRef>) {
return (
<TextClassContext.Provider value={buttonTextVariants({ className })}>
<AlertDialogPrimitive.Action className={cn(buttonVariants(), className)} {...props} />
<AlertDialogPrimitive.Action
className={cn(
buttonVariants(),
// 大字模式下文与行高变大,固定 h-10 会裁切标签(如「知道了」)
'h-auto min-h-14 py-3.5',
className
)}
{...props}
/>
</TextClassContext.Provider>
);
}
@@ -147,7 +170,11 @@ function AlertDialogCancel({
return (
<TextClassContext.Provider value={buttonTextVariants({ className, variant: 'outline' })}>
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: 'outline' }), className)}
className={cn(
buttonVariants({ variant: 'outline' }),
'h-auto min-h-14 py-3.5',
className
)}
{...props}
/>
</TextClassContext.Provider>

View File

@@ -3,7 +3,13 @@ import { cn } from '@/lib/utils';
import * as Slot from '@rn-primitives/slot';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { Platform, Text as RNText, type Role } from 'react-native';
import {
Platform,
StyleSheet,
Text as RNText,
type Role,
type TextStyle,
} from 'react-native';
/**
* Maps Text variant to design-token keys for fontSize and lineHeight.
@@ -122,6 +128,22 @@ const ARIA_LEVEL: Partial<Record<TextVariant, string>> = {
const TextClassContext = React.createContext<string | undefined>(undefined);
/** Android 上 lineHeight < fontSize 会裁切字形(如标题配 lineHeightTight */
const MIN_LINE_HEIGHT_RATIO = 1.2;
function ensureMinimumLineHeight(style: TextStyle): TextStyle {
const fs = style.fontSize;
const lh = style.lineHeight;
if (typeof fs !== 'number' || typeof lh !== 'number') {
return style;
}
const minLh = Math.ceil(fs * MIN_LINE_HEIGHT_RATIO);
if (lh < minLh) {
return { ...style, lineHeight: minLh };
}
return style;
}
function Text({
className,
asChild = false,
@@ -151,12 +173,19 @@ function Text({
}
: undefined;
const flatStyle = StyleSheet.flatten([
{ textAlign: 'left' as const },
typographyStyle,
style,
]) as TextStyle;
const resolvedStyle = ensureMinimumLineHeight(flatStyle);
return (
<Component
className={cn(textVariants({ variant }), textClass, className)}
role={variant ? ROLE[variant] : undefined}
aria-level={variant ? ARIA_LEVEL[variant] : undefined}
style={[{ textAlign: 'left' }, typographyStyle, style]}
style={resolvedStyle}
{...props}
/>
);

View File

@@ -1,12 +1,14 @@
/**
* Typography context for large text mode (大字模式).
* Global typography from design tokens.
*
* - **关大字模式** → `typography.large`(舒适基准,不用过小的 `normal` 档)
* - **开大字模式** → `typography.xlarge`(一级页、正文等整体再放大一档)
*
* 对话气泡等仍可在页面内用 `largeText` 做更大一级的 token 选择。
*
* Uses React Context instead of NativeWind vars() because vars() returns empty
* objects on native (iOS/Android), so CSS variables never cascade to children.
* See: https://github.com/nativewind/nativewind/issues/1113
*
* NativeWind vars() docs (works on web only):
* https://www.nativewind.dev/docs/api/vars
* objects on native (iOS/Android). See:
* https://github.com/nativewind/nativewind/issues/1113
*/
import React, {
createContext,
@@ -19,18 +21,23 @@ import { useAppSettings } from '@/hooks/use-app-settings';
import tokens from '../../design-tokens.json';
export type TypographyMode = 'normal' | 'large';
export type TypographyScale = 'large' | 'xlarge';
export type TypographyTokens = Record<string, number>;
const TypographyContext = createContext<TypographyTokens | null>(null);
const SCALE: Record<TypographyScale, TypographyTokens> = {
large: tokens.typography.large as TypographyTokens,
xlarge: tokens.typography.xlarge as TypographyTokens,
};
export function TypographyProvider({ children }: PropsWithChildren) {
const { largeText } = useAppSettings();
const typography = useMemo(() => {
const mode: TypographyMode = largeText ? 'large' : 'normal';
return tokens.typography[mode] as TypographyTokens;
}, [largeText]);
const typography = useMemo(
() => SCALE[largeText ? 'xlarge' : 'large'],
[largeText],
);
return (
<TypographyContext.Provider value={typography}>

View File

@@ -25,7 +25,9 @@ const typographyVars = (mode) =>
]),
);
// Use default theme for Tailwind base (runtime theme switch via ThemeProvider)
// Use default theme for Tailwind base (runtime theme switch via ThemeProvider).
// Typography vars use the same comfortable scale as TypographyProvider (large),
// not the compact `normal` tier — see typography-context.tsx
const defaultLight = tokens.colors.default.light;
const defaultDark = tokens.colors.default.dark;
@@ -35,7 +37,7 @@ const rootVariables = Object.fromEntries([
value,
]),
['--radius', px(tokens.radius.default)],
...Object.entries(typographyVars('normal')),
...Object.entries(typographyVars('large')),
]);
const darkVariables = Object.fromEntries(