diff --git a/api/app/agents/chat/interview_turn_plan.py b/api/app/agents/chat/interview_turn_plan.py index 8fa8efe..718b82a 100644 --- a/api/app/agents/chat/interview_turn_plan.py +++ b/api/app/agents/chat/interview_turn_plan.py @@ -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 diff --git a/api/app/agents/chat/prompts_conversation.py b/api/app/agents/chat/prompts_conversation.py index 638b1f2..a2c358d 100644 --- a/api/app/agents/chat/prompts_conversation.py +++ b/api/app/agents/chat/prompts_conversation.py @@ -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} diff --git a/api/app/agents/memoir/batch_phase1_prep.py b/api/app/agents/memoir/batch_phase1_prep.py index b2aa8dc..f76ac87 100644 --- a/api/app/agents/memoir/batch_phase1_prep.py +++ b/api/app/agents/memoir/batch_phase1_prep.py @@ -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 diff --git a/api/app/core/memoir_pipeline_progress.py b/api/app/core/memoir_pipeline_progress.py index 232302d..01986c2 100644 --- a/api/app/core/memoir_pipeline_progress.py +++ b/api/app/core/memoir_pipeline_progress.py @@ -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: diff --git a/api/app/features/evaluation/judge_manual_service.py b/api/app/features/evaluation/judge_manual_service.py index a16a949..01c0b43 100644 --- a/api/app/features/evaluation/judge_manual_service.py +++ b/api/app/features/evaluation/judge_manual_service.py @@ -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, - "baseline_excerpt": baseline_excerpt, - "formatted": formatted, "cb2": cb2, "fm": fm, - }) + prepared.append( + { + "ch": ch, + "bl": bl, + "md": md, + "baseline_excerpt": baseline_excerpt, + "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( + { + "event": "chapter_error", + "chapter_id": ch.id, + "title": ch.title, + "message": f"评审失败:{e}", + } ) - 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({ - "event": "chapter_judge", - "index": idx, - "chapter": row, - "ok": cj is not None, - }) + 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)] diff --git a/api/app/features/evaluation/judge_schemas.py b/api/app/features/evaluation/judge_schemas.py index c79e947..bdf4627 100644 --- a/api/app/features/evaluation/judge_schemas.py +++ b/api/app/features/evaluation/judge_schemas.py @@ -229,8 +229,12 @@ class MemoirJudgeOutput(BaseModel): # 细项:校验放宽到 0–100;真实满分仍以 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") diff --git a/api/app/features/evaluation/judge_service.py b/api/app/features/evaluation/judge_service.py index 748ee41..2b93758 100644 --- a/api/app/features/evaluation/judge_service.py +++ b/api/app/features/evaluation/judge_service.py @@ -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) diff --git a/api/app/features/evaluation/memoir_compare_summary.py b/api/app/features/evaluation/memoir_compare_summary.py index 7eec251..29db0d0 100644 --- a/api/app/features/evaluation/memoir_compare_summary.py +++ b/api/app/features/evaluation/memoir_compare_summary.py @@ -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 diff --git a/api/app/features/memory/service.py b/api/app/features/memory/service.py index 9d3a923..0477d91 100644 --- a/api/app/features/memory/service.py +++ b/api/app/features/memory/service.py @@ -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={} " diff --git a/api/app/tasks/celery_app.py b/api/app/tasks/celery_app.py index ce5bece..20cdff8 100644 --- a/api/app/tasks/celery_app.py +++ b/api/app/tasks/celery_app.py @@ -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 "?" diff --git a/api/app/tasks/memoir_tasks.py b/api/app/tasks/memoir_tasks.py index ac9b73d..a7eedc6 100644 --- a/api/app/tasks/memoir_tasks.py +++ b/api/app/tasks/memoir_tasks.py @@ -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( diff --git a/api/app/tasks/story_image_tasks.py b/api/app/tasks/story_image_tasks.py index 8982b13..50ad6f8 100644 --- a/api/app/tasks/story_image_tasks.py +++ b/api/app/tasks/story_image_tasks.py @@ -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。 diff --git a/api/tests/test_batch_phase1_chunked.py b/api/tests/test_batch_phase1_chunked.py index c927bfd..de0af98 100644 --- a/api/tests/test_batch_phase1_chunked.py +++ b/api/tests/test_batch_phase1_chunked.py @@ -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(), diff --git a/api/tests/test_memoir_pipeline_progress.py b/api/tests/test_memoir_pipeline_progress.py index 202ca9d..81c55e8 100644 --- a/api/tests/test_memoir_pipeline_progress.py +++ b/api/tests/test_memoir_pipeline_progress.py @@ -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 diff --git a/api/tests/test_memory_enrichment_baseline.py b/api/tests/test_memory_enrichment_baseline.py index 45f86b7..f18cc1d 100644 --- a/api/tests/test_memory_enrichment_baseline.py +++ b/api/tests/test_memory_enrichment_baseline.py @@ -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) diff --git a/api/tests/test_memory_evidence.py b/api/tests/test_memory_evidence.py index 160daee..a16eed2 100644 --- a/api/tests/test_memory_evidence.py +++ b/api/tests/test_memory_evidence.py @@ -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(), diff --git a/app-expo/design-tokens.json b/app-expo/design-tokens.json index ccc716f..506c93b 100644 --- a/app-expo/design-tokens.json +++ b/app-expo/design-tokens.json @@ -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 } } } diff --git a/app-expo/src/app/(main)/conversation/[id].tsx b/app-expo/src/app/(main)/conversation/[id].tsx index 8f1696c..6bee70e 100644 --- a/app-expo/src/app/(main)/conversation/[id].tsx +++ b/app-expo/src/app/(main)/conversation/[id].tsx @@ -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={ - + {tApp('name')} {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, diff --git a/app-expo/src/app/(tabs)/_layout.tsx b/app-expo/src/app/(tabs)/_layout.tsx index 012cdf4..a5c7d7e 100644 --- a/app-expo/src/app/(tabs)/_layout.tsx +++ b/app-expo/src/app/(tabs)/_layout.tsx @@ -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, diff --git a/app-expo/src/app/(tabs)/index.tsx b/app-expo/src/app/(tabs)/index.tsx index d5290e1..ff28f30 100644 --- a/app-expo/src/app/(tabs)/index.tsx +++ b/app-expo/src/app/(tabs)/index.tsx @@ -154,14 +154,16 @@ function ConversationCard({ {item.title} {formatRelativeConversationListTime(item.latestMessageTime, t)} @@ -202,7 +204,8 @@ function SwipeableConversationCard({ > {t('delete')} @@ -405,11 +408,13 @@ export default function ConversationsScreen() { {t('greetingTitle')} - + {t('emptyGreetingSubtitle')} @@ -429,13 +434,15 @@ export default function ConversationsScreen() { {showResumeEntry ? t('resumeChatTitle') : t('greetingTitle')} - + {showResumeEntry ? t('resumeChatSubtitle') : t('emptyGreetingSubtitle')} @@ -449,10 +456,14 @@ export default function ConversationsScreen() { {t('recentChats')} {}} > - + {t('viewAll')} diff --git a/app-expo/src/app/(tabs)/memoir.tsx b/app-expo/src/app/(tabs)/memoir.tsx index 81244ea..5b539c7 100644 --- a/app-expo/src/app/(tabs)/memoir.tsx +++ b/app-expo/src/app/(tabs)/memoir.tsx @@ -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({ {chapterLabel} @@ -150,7 +161,7 @@ function ChapterCard({ @@ -161,7 +172,8 @@ function ChapterCard({ @@ -174,7 +186,10 @@ function ChapterCard({ style={{ borderCurve: 'continuous' }} onPress={onReadPress} > - + {t('readMemory')} @@ -197,7 +212,8 @@ function ChapterCard({ {chapterLabel} @@ -205,7 +221,7 @@ function ChapterCard({ @@ -213,7 +229,11 @@ function ChapterCard({ - + {t('statusDrafting')} @@ -223,7 +243,7 @@ function ChapterCard({ style={{ borderCurve: 'continuous' }} onPress={onContinuePress} > - + {t('continueWriting')} @@ -237,7 +257,7 @@ function MemoirLoadError({ onRetry }: { onRetry: () => void }) { const { t } = useTranslation('memoir'); return ( - + {t('loadErrorMessage')} void }) { style={{ borderCurve: 'continuous' }} onPress={onRetry} > - + {t('loadErrorRetry')} diff --git a/app-expo/src/app/(tabs)/profile.tsx b/app-expo/src/app/(tabs)/profile.tsx index 5add6f6..9922d82 100644 --- a/app-expo/src/app/(tabs)/profile.tsx +++ b/app-expo/src/app/(tabs)/profile.tsx @@ -90,11 +90,15 @@ function LanguageRow({ {label} - {description} + + {description} + - {currentLabel} + + {currentLabel} + @@ -120,7 +124,9 @@ function SettingRow({ {label} - {description} + + {description} + @@ -203,7 +209,7 @@ export default function ProfileScreen() { {user?.nickname ?? t('userNamePlaceholder')} - + {t('userTier', { tier: tierLabel })} diff --git a/app-expo/src/components/screen-header.tsx b/app-expo/src/components/screen-header.tsx index d9b4202..1f5b376 100644 --- a/app-expo/src/components/screen-header.tsx +++ b/app-expo/src/components/screen-header.tsx @@ -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 && ( ({ - 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 ? ( - + ) : ( - + )} )} {typeof title === 'string' ? ( - {title} - + ) : ( {title} )} diff --git a/app-expo/src/components/ui/alert-dialog.tsx b/app-expo/src/components/ui/alert-dialog.tsx index 98de67b..c146b70 100644 --- a/app-expo/src/components/ui/alert-dialog.tsx +++ b/app-expo/src/components/ui/alert-dialog.tsx @@ -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) { + const typography = useTypography(); return ( ); @@ -113,15 +123,20 @@ function AlertDialogDescription({ ...props }: AlertDialogPrimitive.DescriptionProps & React.RefAttributes) { + const typography = useTypography(); return ( ) { return ( - + ); } @@ -147,7 +170,11 @@ function AlertDialogCancel({ return ( diff --git a/app-expo/src/components/ui/text.tsx b/app-expo/src/components/ui/text.tsx index 6167be3..3dc1fc3 100644 --- a/app-expo/src/components/ui/text.tsx +++ b/app-expo/src/components/ui/text.tsx @@ -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> = { const TextClassContext = React.createContext(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 ( ); diff --git a/app-expo/src/core/typography-context.tsx b/app-expo/src/core/typography-context.tsx index 713a059..61b7f55 100644 --- a/app-expo/src/core/typography-context.tsx +++ b/app-expo/src/core/typography-context.tsx @@ -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; const TypographyContext = createContext(null); +const SCALE: Record = { + 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 ( diff --git a/app-expo/tailwind.config.js b/app-expo/tailwind.config.js index e9da026..9a4ea2e 100644 --- a/app-expo/tailwind.config.js +++ b/app-expo/tailwind.config.js @@ -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(