diff --git a/admin/package-lock.json b/admin/package-lock.json index 62c7538e..030def70 100644 --- a/admin/package-lock.json +++ b/admin/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@supabase/supabase-js": "2.117.2", + "prismjs": "^1.30.0", "react": "19.3.0", "react-dom": "19.3.0", "react-markdown": "10.1.0", @@ -16,6 +17,7 @@ }, "devDependencies": { "@playwright/test": "1.63.0", + "@types/prismjs": "^1.26.5", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "6.1.1", @@ -519,6 +521,13 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/prismjs": { + "version": "1.26.5", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz", + "integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", @@ -2626,6 +2635,15 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/property-information": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", diff --git a/admin/package.json b/admin/package.json index 87fbea36..b79d0598 100644 --- a/admin/package.json +++ b/admin/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@supabase/supabase-js": "2.117.2", + "prismjs": "^1.30.0", "react": "19.3.0", "react-dom": "19.3.0", "react-markdown": "10.1.0", @@ -23,6 +24,7 @@ }, "devDependencies": { "@playwright/test": "1.63.0", + "@types/prismjs": "^1.26.5", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "6.1.1", diff --git a/admin/src/MarkdownText.test.tsx b/admin/src/MarkdownText.test.tsx new file mode 100644 index 00000000..7e70f087 --- /dev/null +++ b/admin/src/MarkdownText.test.tsx @@ -0,0 +1,50 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { MarkdownText } from "./MarkdownText"; +import { LessonView } from "./LessonView"; +import { editableLesson } from "./lesson"; + +describe("lesson Markdown", () => { + it("renders readable blocks and syntax-highlighted fenced code", () => { + const html = renderToStaticMarkup( note\n\n```js\nconst count = 2;\n```"} />); + expect(html).toContain("

Heading

"); + expect(html).toContain("bold"); + expect(html).toContain("
  • one
  • "); + expect(html).toContain("
    "); + expect(html).toContain("
    ");
    +    expect(html).toContain("md-token-keyword");
    +    expect(html).toContain("const");
    +  });
    +
    +  it("keeps unknown code languages readable and does not execute unsafe content", () => {
    +    const html = renderToStaticMarkup(alert(1)\n```\n\n[click](javascript:alert(1))\n\n"} />);
    +    expect(html).toContain("<script>");
    +    expect(html).not.toContain("href=\"javascript:");
    +    expect(html).not.toContain("[click](javascript:");
    +    expect(html).not.toContain(" {
    +    expect(renderToStaticMarkup()).toContain("lesson-markdown");
    +  });
    +
    +  it("keeps adjacent JSON, unknown code, Unicode, and malformed fences readable", () => {
    +    const source = "Plain text with `first` and `second`.\n\n```json\n{\"name\":\"世界\",\"items\":[1,{\"ok\":true}]}\n```\n```odd-language\nraw \n```\n```\nunclosed";
    +    const html = renderToStaticMarkup();
    +    expect(html).toContain("first");
    +    expect(html).toContain("second");
    +    expect(html).toContain("md-token-property");
    +    expect(html).toContain("世界");
    +    expect(html).toContain("raw <tag>");
    +    expect(html).toContain("unclosed");
    +  });
    +
    +  it("uses the same renderer in the reviewer and owner lesson view", () => {
    +    const body = editableLesson({ title: "## Structured Logging", summary: "A **machine-readable** record.", example: "```json\n{\"user_id\":123}\n```" });
    +    const html = renderToStaticMarkup();
    +    expect(html).toContain("Structured Logging");
    +    expect(html).not.toContain("## Structured Logging");
    +    expect(html).toContain("machine-readable");
    +    expect(html).toContain("md-token-property");
    +  });
    +});
    diff --git a/admin/src/MarkdownText.tsx b/admin/src/MarkdownText.tsx
    index 41e3f882..b19940ab 100644
    --- a/admin/src/MarkdownText.tsx
    +++ b/admin/src/MarkdownText.tsx
    @@ -2,10 +2,11 @@ import type { ReactNode } from "react";
     import Markdown from "react-markdown";
     import remarkGfm from "remark-gfm";
     import { safeUrl } from "./api";
    +import { highlightedCode } from "./markdownCode";
     
     /** Lesson Markdown is untrusted content. React renders HTML as text and images are omitted. */
     export function MarkdownText({ value, inline = false, links = true }: { value: unknown; inline?: boolean; links?: boolean }) {
    -  const source = typeof value === "string" ? value : (JSON.stringify(value, null, 2) ?? "Not provided");
    +  const source = typeof value === "string" ? value : value == null ? "" : (JSON.stringify(value, null, 2) ?? "");
       const content = inline ? source.replaceAll("\n", " ") : source;
       const inlineElement = ({ children }: { children?: ReactNode }) => {children};
       const rendered =  {alt ?? "[image]"},
    +      code: ({ className, children }) => {highlightedCode(String(children), className)},
           a: ({ href, children }) => {
             const url = safeUrl(href ?? "");
             return url && links ? {children} : {children};
    diff --git a/admin/src/markdownCode.tsx b/admin/src/markdownCode.tsx
    new file mode 100644
    index 00000000..f8ae77d1
    --- /dev/null
    +++ b/admin/src/markdownCode.tsx
    @@ -0,0 +1,33 @@
    +import type { ReactNode } from "react";
    +import Prism from "prismjs";
    +import "prismjs/components/prism-javascript";
    +import "prismjs/components/prism-typescript";
    +import "prismjs/components/prism-json";
    +import "prismjs/components/prism-python";
    +import "prismjs/components/prism-bash";
    +import "prismjs/components/prism-sql";
    +import "prismjs/components/prism-css";
    +import "prismjs/components/prism-markup";
    +
    +const aliases: Record = {
    +  js: "javascript", jsx: "javascript", javascript: "javascript",
    +  ts: "typescript", tsx: "typescript", typescript: "typescript",
    +  json: "json", py: "python", python: "python",
    +  sh: "bash", shell: "bash", bash: "bash",
    +  sql: "sql", css: "css", html: "markup", xml: "markup",
    +};
    +
    +export function highlightedCode(source: string, className?: string): ReactNode {
    +  const language = aliases[className?.match(/(?:^|\s)language-([\w-]+)/)?.[1].toLowerCase() ?? ""];
    +  const grammar = language && Prism.languages[language];
    +  if (!grammar || source.length > 20_000) return source;
    +
    +  function render(items: (string | Prism.Token)[], prefix = ""): ReactNode[] {
    +    return items.map((item, index) => {
    +      if (typeof item === "string") return item;
    +      const content = typeof item.content === "string" ? item.content : render(Array.isArray(item.content) ? item.content : [item.content], `${prefix}${index}-`);
    +      return {content};
    +    });
    +  }
    +  return render(Prism.tokenize(source, grammar));
    +}
    diff --git a/admin/src/style.css b/admin/src/style.css
    index 84a1103a..6a178ebd 100644
    --- a/admin/src/style.css
    +++ b/admin/src/style.css
    @@ -629,7 +629,10 @@ main {
     .lesson-markdown > :last-child { margin-bottom: 0; }
     .lesson-markdown pre { overflow-x: auto; padding: 14px; border-radius: 8px; background: var(--subtle); }
     .lesson-markdown code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; background: var(--subtle); border-radius: 4px; padding: 1px 3px; }
    -.lesson-markdown pre code { padding: 0; }
    +.lesson-markdown pre code { padding: 0; white-space: pre; background: transparent; overflow-wrap: normal; }
    +.lesson-markdown .md-token-comment, .lesson-markdown .md-token-prolog { color: var(--muted); }
    +.lesson-markdown .md-token-keyword, .lesson-markdown .md-token-boolean { color: var(--primary); }
    +.lesson-markdown .md-token-string, .lesson-markdown .md-token-number { color: var(--success); }
     .lesson-markdown blockquote { border-left: 3px solid var(--primary); margin-left: 0; padding-left: 14px; color: var(--muted); }
     .lesson-markdown table { border-collapse: collapse; display: block; max-width: 100%; overflow-x: auto; }
     .lesson-markdown th, .lesson-markdown td { border: 1px solid var(--line); padding: 6px 10px; }
    diff --git a/backend/app/services/users.py b/backend/app/services/users.py
    index 9d98647e..d2708e83 100644
    --- a/backend/app/services/users.py
    +++ b/backend/app/services/users.py
    @@ -3,38 +3,26 @@
     from sqlalchemy import text
     from sqlalchemy.ext.asyncio import AsyncSession
     
    -# Covers the same ground as the on_auth_user_created trigger — but do NOT
    -# re-sync the two verbatim: the follows insert here is deliberately gated on
    -# profile creation, which the trigger does not need (it only ever runs once).
    -# The trigger covers everyone who signs up normally; this is the safety net
    -# for users that predate the schema, or that were created through paths the
    -# trigger did not see.
    +# Covers the same ground as the on_auth_user_created trigger. The trigger
    +# covers normal signups; this is the safety net for users that predate the
    +# schema or were created through paths the trigger did not see.
     #
    -# Kept to one round trip: it runs on every /v1/daily call (and on
    -# /v1/me/state only when no profile exists yet).
    +# Keep the two inserts in one round trip: this runs on every /v1/daily call
    +# (and on /v1/me/state only when no profile exists yet).
     #
    -# Topic follows are seeded ONLY when the profile insert actually creates a
    -# row. Follows are the one user-curated, deletable set here — an unconditional
    -# insert would resurrect every unfollow on every call, which is exactly what
    -# it used to do (issue #29). Profile and prefs are single rows that users
    -# cannot delete, so re-asserting them stays harmless.
    +# Topic follows are always opt-in, including for users whose signup trigger
    +# did not run. Profile and prefs are single rows that users cannot delete, so
    +# re-asserting them stays harmless.
     _BOOTSTRAP = text("""
    -    with created as (
    +    with profile as (
             insert into public.profiles (id, display_name)
             values (:uid, :display_name)
             on conflict (id) do nothing
             returning id
    -    ), prefs as (
    -        insert into public.notification_preferences (user_id)
    -        values (:uid)
    -        on conflict (user_id) do nothing
         )
    -    insert into public.user_topics (user_id, topic_id)
    -    select p.id, t.id
    -      from created p
    -     cross join public.topics t
    -     where t.is_active
    -    on conflict do nothing
    +    insert into public.notification_preferences (user_id)
    +    values (:uid)
    +    on conflict (user_id) do nothing
     """)
     
     
    diff --git a/backend/migrations/0131_opt_in_followed_topics.sql b/backend/migrations/0131_opt_in_followed_topics.sql
    new file mode 100644
    index 00000000..7f9ae1aa
    --- /dev/null
    +++ b/backend/migrations/0131_opt_in_followed_topics.sql
    @@ -0,0 +1,26 @@
    +-- New accounts choose their own interests. Existing user_topics rows are untouched.
    +begin;
    +
    +create or replace function public.handle_new_user()
    +returns trigger
    +language plpgsql
    +security definer
    +set search_path = ''
    +as $$
    +begin
    +  insert into public.profiles (id, display_name)
    +  values (
    +    new.id,
    +    coalesce(new.raw_user_meta_data ->> 'display_name', split_part(new.email, '@', 1))
    +  )
    +  on conflict (id) do nothing;
    +
    +  insert into public.notification_preferences (user_id)
    +  values (new.id)
    +  on conflict (user_id) do nothing;
    +
    +  return new;
    +end;
    +$$;
    +
    +commit;
    diff --git a/backend/migrations/0132_opt_in_edge_bootstrap.sql b/backend/migrations/0132_opt_in_edge_bootstrap.sql
    new file mode 100644
    index 00000000..0393201a
    --- /dev/null
    +++ b/backend/migrations/0132_opt_in_edge_bootstrap.sql
    @@ -0,0 +1,370 @@
    +-- Keep every Edge API first-request path opt-in when the Auth trigger was skipped.
    +-- Existing follows and the explicit set-topics RPC are untouched.
    +begin;
    +
    +-- edge_api_daily: bootstrap profile/preferences without choosing topics.
    +CREATE OR REPLACE FUNCTION public.edge_api_daily(p_user_id uuid, p_low integer DEFAULT 10, p_reserve integer DEFAULT 60, p_active_days integer DEFAULT 90, p_today date DEFAULT NULL::date)
    + RETURNS jsonb
    + LANGUAGE plpgsql
    + SECURITY DEFINER
    + SET search_path TO ''
    +AS $function$
    +declare
    +  v_today date;
    +  v_timezone text;
    +  v_concept uuid;
    +  v_topic uuid;
    +  v_assignment uuid;
    +  v_payload jsonb;
    +begin
    +  if p_low < 0 or p_low > 30 or p_reserve < 1 or p_reserve > 365
    +     or p_active_days < 1 or p_active_days > 365 then
    +    raise exception 'Invalid supply configuration';
    +  end if;
    +
    +  -- Match the legacy-user safety net without restoring deliberately unfollowed topics.
    +  insert into public.profiles(id, display_name)
    +  select p_user_id, split_part(u.email, '@', 1)
    +    from auth.users u where u.id = p_user_id
    +  on conflict(id) do nothing;
    +  insert into public.notification_preferences(user_id) values (p_user_id)
    +  on conflict(user_id) do nothing;
    +
    +  select timezone into v_timezone
    +    from public.profiles where id = p_user_id for update;
    +  if v_timezone is null then
    +    raise exception 'Profile unavailable';
    +  end if;
    +  v_today := coalesce(p_today, (now() at time zone v_timezone)::date);
    +
    +  if exists(select 1 from public.daily_reviews
    +    where user_id = p_user_id and assigned_for = v_today) then
    +    return jsonb_build_object('assigned_for', v_today, 'reason', 'catalog_exhausted',
    +      'detail', 'You have already been assigned every available concept.');
    +  end if;
    +  if exists(select 1 from public.daily_assignments
    +    where user_id = p_user_id and assigned_for = v_today) then
    +    v_payload := public.edge_api_daily_payload(p_user_id, v_today);
    +    if v_payload is not null then return v_payload; end if;
    +    return jsonb_build_object('assigned_for', v_today, 'reason', 'catalog_exhausted',
    +      'detail', 'You have already been assigned every available concept.');
    +  end if;
    +
    +  if not exists(select 1 from public.user_topics ut
    +    join public.topics t on t.id = ut.topic_id and t.is_active
    +    where ut.user_id = p_user_id) then
    +    return jsonb_build_object('assigned_for', v_today, 'reason', 'personalization_required',
    +      'detail', 'Follow at least one topic to receive a daily concept.');
    +  end if;
    +
    +  -- Move the most recent unfinished followed lesson, retaining its identity and timestamp.
    +  with pending as (
    +    select a.id from public.daily_assignments a
    +    join public.concepts c on c.id = a.concept_id and c.status = 'published'
    +    join public.topics t on t.id = c.topic_id and t.is_active
    +    join public.subtopics s on s.id = c.subtopic_id and s.is_active
    +    join public.user_topics ut on ut.topic_id = c.topic_id and ut.user_id = p_user_id
    +    where a.user_id = p_user_id and a.completed_at is null and a.assigned_for < v_today
    +    order by a.assigned_for desc, a.id limit 1
    +  )
    +  update public.daily_assignments a set assigned_for = v_today
    +    from pending where a.id = pending.id returning a.concept_id into v_concept;
    +  if v_concept is not null then
    +    select topic_id into v_topic from public.concepts where id = v_concept;
    +    perform public.edge_api_signal_supply(p_user_id, v_topic, p_low, p_reserve, p_active_days);
    +    return public.edge_api_daily_payload(p_user_id, v_today);
    +  end if;
    +
    +  with pool as (
    +    select c.id, c.topic_id, coalesce(c.difficulty, 1) as level,
    +      (select count(*) from jsonb_array_elements_text(
    +        coalesce(c.curriculum->'prerequisites', '[]'::jsonb)) required(slug)
    +        where not exists(select 1 from public.daily_assignments learned
    +          join public.concepts prior on prior.id = learned.concept_id
    +          where learned.user_id = p_user_id and learned.completed_at is not null
    +            and prior.slug = required.slug)) as unmet
    +      from public.concepts c
    +      join public.topics t on t.id = c.topic_id and t.is_active
    +      join public.subtopics s on s.id = c.subtopic_id and s.is_active
    +      where c.status = 'published'
    +        and exists(select 1 from public.user_topics ut
    +          where ut.user_id = p_user_id and ut.topic_id = c.topic_id)
    +        and not exists(select 1 from public.daily_assignments a
    +          where a.user_id = p_user_id and a.concept_id = c.id)
    +  ), last_seen as (
    +    select c.topic_id, max(a.assigned_for) as seen_on
    +      from public.daily_assignments a
    +      join public.concepts c on c.id = a.concept_id and c.status = 'published'
    +     where a.user_id = p_user_id group by c.topic_id
    +  )
    +  select pool.id, pool.topic_id into v_concept, v_topic
    +    from pool left join last_seen on last_seen.topic_id = pool.topic_id
    +   order by last_seen.seen_on asc nulls first, pool.unmet asc, pool.level asc, random()
    +   limit 1;
    +
    +  if v_concept is not null then
    +    begin
    +      insert into public.daily_assignments(id, user_id, concept_id, assigned_for)
    +      values (gen_random_uuid(), p_user_id, v_concept, v_today)
    +      on conflict(user_id, assigned_for) do nothing returning id into v_assignment;
    +    exception when unique_violation then
    +      v_assignment := null;
    +    end;
    +    if v_assignment is not null then
    +      perform public.edge_api_signal_supply(p_user_id, v_topic, p_low, p_reserve, p_active_days);
    +      v_payload := public.edge_api_daily_payload(p_user_id, v_today);
    +      if v_payload is not null then return v_payload; end if;
    +    else
    +      v_payload := public.edge_api_daily_payload(p_user_id, v_today);
    +      if v_payload is not null then return v_payload; end if;
    +    end if;
    +  else
    +    -- Request future supply in a followed topic without waiting for generation.
    +    select ut.topic_id into v_topic from public.user_topics ut
    +    join public.topics t on t.id = ut.topic_id and t.is_active
    +    left join (
    +      select c.topic_id, max(a.assigned_for) as seen_on
    +      from public.daily_assignments a
    +      join public.concepts c on c.id = a.concept_id and c.status = 'published'
    +      where a.user_id = p_user_id group by c.topic_id
    +    ) seen on seen.topic_id = ut.topic_id
    +    where ut.user_id = p_user_id
    +    order by seen.seen_on asc nulls first limit 1;
    +    if v_topic is not null then
    +      perform public.edge_api_signal_supply(p_user_id, v_topic, p_low, p_reserve, p_active_days);
    +    end if;
    +  end if;
    +  return jsonb_build_object('assigned_for', v_today, 'reason', 'catalog_exhausted',
    +    'detail', 'You have already been assigned every available concept.');
    +end
    +$function$
    +;
    +
    +-- edge_api_achievements: bootstrap profile/preferences without choosing topics.
    +CREATE OR REPLACE FUNCTION public.edge_api_achievements(p_user_id uuid)
    + RETURNS jsonb
    + LANGUAGE plpgsql
    + SECURITY DEFINER
    + SET search_path TO ''
    +AS $function$
    +declare
    +  v_stats jsonb;
    +  v_existing text[];
    +  v_items jsonb;
    +begin
    +  -- Match first-request bootstrap without creating a daily assignment.
    +  insert into public.profiles(id, display_name)
    +  select p_user_id, split_part(u.email, '@', 1)
    +    from auth.users u where u.id = p_user_id
    +  on conflict(id) do nothing;
    +  insert into public.notification_preferences(user_id) values (p_user_id)
    +  on conflict(user_id) do nothing;
    +  perform id from public.profiles where id = p_user_id for update;
    +  if not found then return null; end if;
    +
    +  select coalesce(array_agg(achievement_code), '{}'::text[]) into v_existing
    +    from public.user_achievements where user_id = p_user_id;
    +  perform public.edge_api_award_eligible(p_user_id);
    +  -- Awards discovered on collection are historical reconciliation, not a new tap.
    +  update public.user_achievements set source = 'history'
    +   where user_id = p_user_id and not (achievement_code = any(v_existing));
    +  v_stats := public.edge_api_stats(p_user_id);
    +
    +  with counts as (
    +    select 'completed_concepts'::text as metric, count(*)::integer as value
    +      from public.user_concept_completions where user_id = p_user_id
    +    union all
    +    select 'completed_reviews', count(*)::integer from public.daily_reviews
    +      where user_id = p_user_id and completed_at is not null
    +    union all
    +    select 'weekly_quizzes_completed', count(*)::integer from (
    +      select quiz_id from public.weekly_quiz_attempts where user_id = p_user_id group by quiz_id
    +    ) quizzes
    +    union all
    +    select 'weekly_perfect_scores', count(*)::integer from (
    +      select a.quiz_id from public.weekly_quiz_attempts a
    +      join public.weekly_quizzes q on q.id = a.quiz_id and q.user_id = a.user_id
    +      where a.user_id = p_user_id and a.correct_count = jsonb_array_length(q.questions)
    +      group by a.quiz_id
    +    ) quizzes
    +    union all
    +    select 'completed_subtopics', count(*)::integer from (
    +      select subtopic_id from public.user_subtopic_completions
    +      where user_id = p_user_id group by subtopic_id
    +    ) subtopics
    +  ), rows as (
    +    select d.*, a.earned_on, a.source, a.seen_at,
    +      case when d.metric = 'consecutive_days' then (v_stats->>'current')::integer
    +           else coalesce(c.value, 0) end as progress
    +      from public.achievement_definitions d
    +      left join public.user_achievements a
    +        on a.achievement_code = d.code and a.user_id = p_user_id
    +      left join counts c on c.metric = d.metric
    +  )
    +  select coalesce(jsonb_agg(jsonb_build_object(
    +    'code', code, 'metric', metric, 'threshold', threshold,
    +    'name', name, 'description', description, 'artwork_key', artwork_key,
    +    'category', category, 'requirement', requirement,
    +    'show_progress', show_progress,
    +    'progress', case when show_progress then least(progress, threshold) else null end,
    +    'earned_on', earned_on, 'source', source, 'seen_at', seen_at
    +  ) order by sort_order, code), '[]'::jsonb) into v_items from rows;
    +  return jsonb_build_object(
    +    'current_streak', (v_stats->>'current')::integer,
    +    'longest_streak', (v_stats->>'longest')::integer,
    +    'items', v_items
    +  );
    +end
    +$function$
    +;
    +
    +-- edge_api_weekly_quiz: bootstrap profile/preferences without choosing topics.
    +CREATE OR REPLACE FUNCTION public.edge_api_weekly_quiz(p_user_id uuid)
    + RETURNS jsonb
    + LANGUAGE plpgsql
    + SECURITY DEFINER
    + SET search_path TO ''
    +AS $function$
    +declare
    +  v_week date := current_date - (extract(isodow from current_date)::integer - 1);
    +  v_quiz_id uuid;
    +  v_questions jsonb;
    +  v_public jsonb;
    +  v_count integer;
    +begin
    +  if not exists(select 1 from auth.users where id = p_user_id) then return null; end if;
    +  insert into public.profiles(id, display_name)
    +  select p_user_id, split_part(u.email, '@', 1)
    +    from auth.users u where u.id = p_user_id
    +  on conflict(id) do nothing;
    +  insert into public.notification_preferences(user_id) values(p_user_id)
    +  on conflict(user_id) do nothing;
    +  perform 1 from public.profiles where id = p_user_id for update;
    +  if not found then return null; end if;
    +
    +  select id, questions into v_quiz_id, v_questions
    +    from public.weekly_quizzes where user_id = p_user_id and week_start = v_week;
    +  if v_quiz_id is null then
    +    with recent as (
    +      select c.id, c.slug, c.title, c.content_version, c.mcqs,
    +             max(a.completed_at) as completed_at
    +        from public.daily_assignments a
    +        join public.concepts c on c.id = a.concept_id
    +       where a.user_id = p_user_id and a.completed_at is not null
    +         and c.status = 'published' and jsonb_typeof(c.mcqs) = 'array'
    +       group by c.id, c.slug, c.title, c.content_version, c.mcqs
    +      having jsonb_array_length(c.mcqs) = 3
    +       order by max(a.completed_at) desc, c.id
    +       limit 7
    +    ), selected as (
    +      select recent.*,
    +        (get_byte(pg_catalog.sha256(convert_to(
    +          p_user_id::text || ':' || v_week::text || ':' || recent.id::text, 'UTF8')), 0) * 256
    +         + get_byte(pg_catalog.sha256(convert_to(
    +          p_user_id::text || ':' || v_week::text || ':' || recent.id::text, 'UTF8')), 1)) % 3
    +        as question_index
    +      from recent
    +    )
    +    select count(*)::integer,
    +      coalesce(jsonb_agg(jsonb_build_object(
    +        'id', id::text || ':' || content_version::text || ':' || question_index::text,
    +        'concept_slug', slug, 'concept_title', title,
    +        'content_version', content_version,
    +        'question', mcqs -> question_index -> 'question',
    +        'options', mcqs -> question_index -> 'options',
    +        'correct_index', mcqs -> question_index -> 'correct_index'
    +      ) order by completed_at desc, id), '[]'::jsonb)
    +      into v_count, v_questions from selected;
    +    if v_count < 7 then
    +      return jsonb_build_object('available', false, 'week_start', v_week,
    +        'required_concepts', 7, 'available_concepts', v_count,
    +        'detail', 'Complete more concepts with reviewed questions to unlock this weekly quiz.');
    +    end if;
    +    insert into public.weekly_quizzes(user_id, week_start, questions)
    +    values(p_user_id, v_week, v_questions) returning id into v_quiz_id;
    +  end if;
    +  select coalesce(jsonb_agg(q.value - 'correct_index' order by q.ordinality), '[]'::jsonb)
    +    into v_public from jsonb_array_elements(v_questions) with ordinality as q(value, ordinality);
    +  return jsonb_build_object('available', true, 'quiz_id', v_quiz_id,
    +    'week_start', v_week, 'questions', v_public);
    +end
    +$function$
    +;
    +
    +-- edge_api_subtopic_quiz: bootstrap profile/preferences without choosing topics.
    +CREATE OR REPLACE FUNCTION public.edge_api_subtopic_quiz(p_user_id uuid, p_completion_id uuid)
    + RETURNS jsonb
    + LANGUAGE plpgsql
    + SECURITY DEFINER
    + SET search_path TO ''
    +AS $function$
    +declare
    +  v_completion record;
    +  v_quiz_id uuid;
    +  v_questions jsonb;
    +  v_public jsonb;
    +  v_reviewed integer;
    +  v_required integer;
    +begin
    +  if not exists(select 1 from auth.users where id = p_user_id) then return null; end if;
    +  insert into public.profiles(id, display_name)
    +  select p_user_id, split_part(u.email, '@', 1)
    +    from auth.users u where u.id = p_user_id
    +  on conflict(id) do nothing;
    +  insert into public.notification_preferences(user_id) values(p_user_id)
    +  on conflict(user_id) do nothing;
    +  perform 1 from public.profiles where id = p_user_id for update;
    +  if not found then return null; end if;
    +  select e.catalog_concept_ids, t.name as topic_name, s.name as subtopic_name
    +    into v_completion from public.user_subtopic_completions e
    +    join public.subtopics s on s.id = e.subtopic_id
    +    join public.topics t on t.id = s.topic_id
    +   where e.id = p_completion_id and e.user_id = p_user_id;
    +  if not found then return jsonb_build_object('error', 'unavailable'); end if;
    +  select id, questions into v_quiz_id, v_questions from public.subtopic_quizzes
    +   where user_id = p_user_id and subtopic_completion_id = p_completion_id;
    +  if v_quiz_id is null then
    +    v_required := least(7, cardinality(v_completion.catalog_concept_ids));
    +    with candidates as (
    +      select c.id, c.slug, c.title, c.content_version, c.mcqs,
    +        pg_catalog.sha256(convert_to(p_completion_id::text || ':select:' || c.id::text, 'UTF8')) as sort_key,
    +        (get_byte(pg_catalog.sha256(convert_to(
    +          p_completion_id::text || ':' || c.id::text, 'UTF8')), 0) * 256
    +         + get_byte(pg_catalog.sha256(convert_to(
    +          p_completion_id::text || ':' || c.id::text, 'UTF8')), 1)) % 3 as question_index
    +        from public.concepts c
    +       where c.id = any(v_completion.catalog_concept_ids)
    +         and c.status = 'published' and jsonb_typeof(c.mcqs) = 'array'
    +         and jsonb_array_length(c.mcqs) = 3
    +       order by sort_key, c.id
    +       limit v_required
    +    )
    +    select count(*)::integer,
    +      coalesce(jsonb_agg(jsonb_build_object(
    +        'id', id::text || ':' || content_version::text || ':' || question_index::text,
    +        'concept_slug', slug, 'concept_title', title,
    +        'content_version', content_version,
    +        'question', mcqs -> question_index -> 'question',
    +        'options', mcqs -> question_index -> 'options',
    +        'correct_index', mcqs -> question_index -> 'correct_index'
    +      ) order by sort_key, id), '[]'::jsonb)
    +      into v_reviewed, v_questions from candidates;
    +    if v_reviewed < v_required then
    +      return jsonb_build_object('available', false, 'completion_id', p_completion_id,
    +        'topic_name', v_completion.topic_name, 'subtopic_name', v_completion.subtopic_name,
    +        'reviewed_concepts', v_reviewed, 'required_concepts', v_required,
    +        'detail', 'This optional quiz will unlock when enough reviewed questions are available.');
    +    end if;
    +    insert into public.subtopic_quizzes(user_id, subtopic_completion_id, questions)
    +    values(p_user_id, p_completion_id, v_questions) returning id into v_quiz_id;
    +  end if;
    +  select coalesce(jsonb_agg(q.value - 'correct_index' order by q.ordinality), '[]'::jsonb)
    +    into v_public from jsonb_array_elements(v_questions) with ordinality as q(value, ordinality);
    +  return jsonb_build_object('available', true, 'quiz_id', v_quiz_id,
    +    'completion_id', p_completion_id, 'topic_name', v_completion.topic_name,
    +    'subtopic_name', v_completion.subtopic_name, 'questions', v_public);
    +end
    +$function$
    +;
    +
    +commit;
    diff --git a/backend/schema/contract.json b/backend/schema/contract.json
    index dd202b08..0f7b9909 100644
    --- a/backend/schema/contract.json
    +++ b/backend/schema/contract.json
    @@ -129,7 +129,9 @@
         "0127_supabase_legacy_enrichment_worker.sql": "6cbd73098ab8cc42c85a9c2d6584c27f2d76cc0484217f25da9db02209f18e55",
         "0128_supabase_worker_owner_reporting.sql": "2df8d9f6ae0f9f99a6210f1d015651f00d9dc8373ba1edeaef84f1e2d5fc0afd",
         "0129_supabase_worker_run_retention.sql": "21deceb956f640102aba8728b65f64da8c84c4675f18ed8bbe3f7917172b9524",
    -    "0130_supabase_email_ambiguous_delivery.sql": "c9916265e8cc34832b2f666a0d6584185c7fccac2e5cc7ee12392de40b0f5bd5"
    +    "0130_supabase_email_ambiguous_delivery.sql": "c9916265e8cc34832b2f666a0d6584185c7fccac2e5cc7ee12392de40b0f5bd5",
    +    "0131_opt_in_followed_topics.sql": "25238ed6e4ad41ea905213cbdbff7697f033f85b730eee3acc4a9ea5f958b08b",
    +    "0132_opt_in_edge_bootstrap.sql": "0521b93968b4abb5f016547cfc968f2b0742334f4af976fb09fac24ca5d5f767"
       },
       "schema": {
         "columns": {
    @@ -3207,7 +3209,7 @@
             "definition": "CREATE OR REPLACE FUNCTION public.capture_editorial_notification()\n RETURNS trigger\n LANGUAGE plpgsql\n SET search_path TO 'pg_catalog'\nAS $function$\ndeclare changed boolean; hours integer;\nbegin\n  changed := TG_OP='INSERT';\n  if TG_OP='UPDATE' then\n    changed := new.assigned_to is distinct from old.assigned_to\n      or new.review_due_at is distinct from old.review_due_at\n      or (new.status in ('draft','pending_review')) <> (old.status in ('draft','pending_review'));\n  end if;\n  if changed then\n    new.notification_epoch := coalesce(new.notification_epoch,0)+1;\n    if new.assigned_to is not null and new.status in ('draft','pending_review') then\n      new.review_assigned_at := statement_timestamp();\n      if new.review_due_at is null then\n        select deadline_hours into hours from public.editorial_notification_policy;\n        new.review_due_at := statement_timestamp() + make_interval(hours=>hours);\n      end if;\n    end if;\n  end if;\n  return new;\nend $function$\n"
           },
           "edge_api_achievements": {
    -        "definition": "CREATE OR REPLACE FUNCTION public.edge_api_achievements(p_user_id uuid)\n RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $function$\ndeclare\n  v_stats jsonb;\n  v_existing text[];\n  v_items jsonb;\nbegin\n  -- Match first-request bootstrap without creating a daily assignment.\n  with created as (\n    insert into public.profiles(id, display_name)\n    select p_user_id, split_part(u.email, '@', 1)\n      from auth.users u where u.id = p_user_id\n    on conflict(id) do nothing returning id\n  )\n  insert into public.user_topics(user_id, topic_id)\n  select created.id, t.id from created cross join public.topics t where t.is_active\n  on conflict do nothing;\n  insert into public.notification_preferences(user_id) values (p_user_id)\n  on conflict(user_id) do nothing;\n  perform id from public.profiles where id = p_user_id for update;\n  if not found then return null; end if;\n\n  select coalesce(array_agg(achievement_code), '{}'::text[]) into v_existing\n    from public.user_achievements where user_id = p_user_id;\n  perform public.edge_api_award_eligible(p_user_id);\n  -- Awards discovered on collection are historical reconciliation, not a new tap.\n  update public.user_achievements set source = 'history'\n   where user_id = p_user_id and not (achievement_code = any(v_existing));\n  v_stats := public.edge_api_stats(p_user_id);\n\n  with counts as (\n    select 'completed_concepts'::text as metric, count(*)::integer as value\n      from public.user_concept_completions where user_id = p_user_id\n    union all\n    select 'completed_reviews', count(*)::integer from public.daily_reviews\n      where user_id = p_user_id and completed_at is not null\n    union all\n    select 'weekly_quizzes_completed', count(*)::integer from (\n      select quiz_id from public.weekly_quiz_attempts where user_id = p_user_id group by quiz_id\n    ) quizzes\n    union all\n    select 'weekly_perfect_scores', count(*)::integer from (\n      select a.quiz_id from public.weekly_quiz_attempts a\n      join public.weekly_quizzes q on q.id = a.quiz_id and q.user_id = a.user_id\n      where a.user_id = p_user_id and a.correct_count = jsonb_array_length(q.questions)\n      group by a.quiz_id\n    ) quizzes\n    union all\n    select 'completed_subtopics', count(*)::integer from (\n      select subtopic_id from public.user_subtopic_completions\n      where user_id = p_user_id group by subtopic_id\n    ) subtopics\n  ), rows as (\n    select d.*, a.earned_on, a.source, a.seen_at,\n      case when d.metric = 'consecutive_days' then (v_stats->>'current')::integer\n           else coalesce(c.value, 0) end as progress\n      from public.achievement_definitions d\n      left join public.user_achievements a\n        on a.achievement_code = d.code and a.user_id = p_user_id\n      left join counts c on c.metric = d.metric\n  )\n  select coalesce(jsonb_agg(jsonb_build_object(\n    'code', code, 'metric', metric, 'threshold', threshold,\n    'name', name, 'description', description, 'artwork_key', artwork_key,\n    'category', category, 'requirement', requirement,\n    'show_progress', show_progress,\n    'progress', case when show_progress then least(progress, threshold) else null end,\n    'earned_on', earned_on, 'source', source, 'seen_at', seen_at\n  ) order by sort_order, code), '[]'::jsonb) into v_items from rows;\n  return jsonb_build_object(\n    'current_streak', (v_stats->>'current')::integer,\n    'longest_streak', (v_stats->>'longest')::integer,\n    'items', v_items\n  );\nend\n$function$\n"
    +        "definition": "CREATE OR REPLACE FUNCTION public.edge_api_achievements(p_user_id uuid)\n RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $function$\ndeclare\n  v_stats jsonb;\n  v_existing text[];\n  v_items jsonb;\nbegin\n  -- Match first-request bootstrap without creating a daily assignment.\n  insert into public.profiles(id, display_name)\n  select p_user_id, split_part(u.email, '@', 1)\n    from auth.users u where u.id = p_user_id\n  on conflict(id) do nothing;\n  insert into public.notification_preferences(user_id) values (p_user_id)\n  on conflict(user_id) do nothing;\n  perform id from public.profiles where id = p_user_id for update;\n  if not found then return null; end if;\n\n  select coalesce(array_agg(achievement_code), '{}'::text[]) into v_existing\n    from public.user_achievements where user_id = p_user_id;\n  perform public.edge_api_award_eligible(p_user_id);\n  -- Awards discovered on collection are historical reconciliation, not a new tap.\n  update public.user_achievements set source = 'history'\n   where user_id = p_user_id and not (achievement_code = any(v_existing));\n  v_stats := public.edge_api_stats(p_user_id);\n\n  with counts as (\n    select 'completed_concepts'::text as metric, count(*)::integer as value\n      from public.user_concept_completions where user_id = p_user_id\n    union all\n    select 'completed_reviews', count(*)::integer from public.daily_reviews\n      where user_id = p_user_id and completed_at is not null\n    union all\n    select 'weekly_quizzes_completed', count(*)::integer from (\n      select quiz_id from public.weekly_quiz_attempts where user_id = p_user_id group by quiz_id\n    ) quizzes\n    union all\n    select 'weekly_perfect_scores', count(*)::integer from (\n      select a.quiz_id from public.weekly_quiz_attempts a\n      join public.weekly_quizzes q on q.id = a.quiz_id and q.user_id = a.user_id\n      where a.user_id = p_user_id and a.correct_count = jsonb_array_length(q.questions)\n      group by a.quiz_id\n    ) quizzes\n    union all\n    select 'completed_subtopics', count(*)::integer from (\n      select subtopic_id from public.user_subtopic_completions\n      where user_id = p_user_id group by subtopic_id\n    ) subtopics\n  ), rows as (\n    select d.*, a.earned_on, a.source, a.seen_at,\n      case when d.metric = 'consecutive_days' then (v_stats->>'current')::integer\n           else coalesce(c.value, 0) end as progress\n      from public.achievement_definitions d\n      left join public.user_achievements a\n        on a.achievement_code = d.code and a.user_id = p_user_id\n      left join counts c on c.metric = d.metric\n  )\n  select coalesce(jsonb_agg(jsonb_build_object(\n    'code', code, 'metric', metric, 'threshold', threshold,\n    'name', name, 'description', description, 'artwork_key', artwork_key,\n    'category', category, 'requirement', requirement,\n    'show_progress', show_progress,\n    'progress', case when show_progress then least(progress, threshold) else null end,\n    'earned_on', earned_on, 'source', source, 'seen_at', seen_at\n  ) order by sort_order, code), '[]'::jsonb) into v_items from rows;\n  return jsonb_build_object(\n    'current_streak', (v_stats->>'current')::integer,\n    'longest_streak', (v_stats->>'longest')::integer,\n    'items', v_items\n  );\nend\n$function$\n"
           },
           "edge_api_achievements_seen": {
             "definition": "CREATE OR REPLACE FUNCTION public.edge_api_achievements_seen(p_user_id uuid, p_codes text[])\n RETURNS void\n LANGUAGE sql\n SET search_path TO ''\nAS $function$\n  update public.user_achievements set seen_at = coalesce(seen_at, now())\n   where user_id = p_user_id and achievement_code = any(p_codes)\n$function$\n"
    @@ -3228,7 +3230,7 @@
             "definition": "CREATE OR REPLACE FUNCTION public.edge_api_concept(p_user_id uuid, p_slug text)\n RETURNS jsonb\n LANGUAGE sql\n STABLE\n SET search_path TO ''\nAS $function$\n  select jsonb_build_object(\n    'id', c.id,\n    'slug', c.slug,\n    'title', c.title,\n    'summary', c.summary,\n    'example', c.example,\n    'flashcard', c.flashcard,\n    'topic_slug', t.slug,\n    'topic_name', t.name,\n    'subtopic_slug', s.slug,\n    'subtopic_name', s.name,\n    'like_count', (\n      select count(*)::integer from public.concept_interactions ci\n      where ci.concept_id = c.id and ci.liked_at is not null\n        and ci.user_id <> p_user_id\n    ),\n    'content_version', c.content_version,\n    'review', (\n      select jsonb_build_object(\n        'name', e.registered_name,\n        'reviewed_at', e.created_at,\n        'content_version', p.content_version\n      )\n      from public.editorial_publications p\n      join public.editorial_revision_events e on e.id = p.approval_id\n      where p.concept_id = c.id and p.content_version = c.content_version\n        and p.snapshot = to_jsonb(c) - array['created_at', 'published_at', 'status']\n        and e.concept_id = c.id and e.action in ('approved', 'attested')\n    )\n  )\n  from public.concepts c\n  join public.topics t on t.id = c.topic_id\n  join public.subtopics s on s.id = c.subtopic_id\n  where c.slug = p_slug and c.status = 'published'\n$function$\n"
           },
           "edge_api_daily": {
    -        "definition": "CREATE OR REPLACE FUNCTION public.edge_api_daily(p_user_id uuid, p_low integer DEFAULT 10, p_reserve integer DEFAULT 60, p_active_days integer DEFAULT 90, p_today date DEFAULT NULL::date)\n RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $function$\ndeclare\n  v_today date;\n  v_timezone text;\n  v_concept uuid;\n  v_topic uuid;\n  v_assignment uuid;\n  v_payload jsonb;\nbegin\n  if p_low < 0 or p_low > 30 or p_reserve < 1 or p_reserve > 365\n     or p_active_days < 1 or p_active_days > 365 then\n    raise exception 'Invalid supply configuration';\n  end if;\n\n  -- Match the legacy-user safety net without restoring deliberately unfollowed topics.\n  with created as (\n    insert into public.profiles(id, display_name)\n    select p_user_id, split_part(u.email, '@', 1)\n      from auth.users u where u.id = p_user_id\n    on conflict(id) do nothing returning id\n  ), prefs as (\n    insert into public.notification_preferences(user_id) values (p_user_id)\n    on conflict(user_id) do nothing\n  )\n  insert into public.user_topics(user_id, topic_id)\n  select created.id, t.id from created cross join public.topics t where t.is_active\n  on conflict do nothing;\n\n  select timezone into v_timezone\n    from public.profiles where id = p_user_id for update;\n  if v_timezone is null then\n    raise exception 'Profile unavailable';\n  end if;\n  v_today := coalesce(p_today, (now() at time zone v_timezone)::date);\n\n  if exists(select 1 from public.daily_reviews\n    where user_id = p_user_id and assigned_for = v_today) then\n    return jsonb_build_object('assigned_for', v_today, 'reason', 'catalog_exhausted',\n      'detail', 'You have already been assigned every available concept.');\n  end if;\n  if exists(select 1 from public.daily_assignments\n    where user_id = p_user_id and assigned_for = v_today) then\n    v_payload := public.edge_api_daily_payload(p_user_id, v_today);\n    if v_payload is not null then return v_payload; end if;\n    return jsonb_build_object('assigned_for', v_today, 'reason', 'catalog_exhausted',\n      'detail', 'You have already been assigned every available concept.');\n  end if;\n\n  if not exists(select 1 from public.user_topics ut\n    join public.topics t on t.id = ut.topic_id and t.is_active\n    where ut.user_id = p_user_id) then\n    return jsonb_build_object('assigned_for', v_today, 'reason', 'personalization_required',\n      'detail', 'Follow at least one topic to receive a daily concept.');\n  end if;\n\n  -- Move the most recent unfinished followed lesson, retaining its identity and timestamp.\n  with pending as (\n    select a.id from public.daily_assignments a\n    join public.concepts c on c.id = a.concept_id and c.status = 'published'\n    join public.topics t on t.id = c.topic_id and t.is_active\n    join public.subtopics s on s.id = c.subtopic_id and s.is_active\n    join public.user_topics ut on ut.topic_id = c.topic_id and ut.user_id = p_user_id\n    where a.user_id = p_user_id and a.completed_at is null and a.assigned_for < v_today\n    order by a.assigned_for desc, a.id limit 1\n  )\n  update public.daily_assignments a set assigned_for = v_today\n    from pending where a.id = pending.id returning a.concept_id into v_concept;\n  if v_concept is not null then\n    select topic_id into v_topic from public.concepts where id = v_concept;\n    perform public.edge_api_signal_supply(p_user_id, v_topic, p_low, p_reserve, p_active_days);\n    return public.edge_api_daily_payload(p_user_id, v_today);\n  end if;\n\n  with pool as (\n    select c.id, c.topic_id, coalesce(c.difficulty, 1) as level,\n      (select count(*) from jsonb_array_elements_text(\n        coalesce(c.curriculum->'prerequisites', '[]'::jsonb)) required(slug)\n        where not exists(select 1 from public.daily_assignments learned\n          join public.concepts prior on prior.id = learned.concept_id\n          where learned.user_id = p_user_id and learned.completed_at is not null\n            and prior.slug = required.slug)) as unmet\n      from public.concepts c\n      join public.topics t on t.id = c.topic_id and t.is_active\n      join public.subtopics s on s.id = c.subtopic_id and s.is_active\n      where c.status = 'published'\n        and exists(select 1 from public.user_topics ut\n          where ut.user_id = p_user_id and ut.topic_id = c.topic_id)\n        and not exists(select 1 from public.daily_assignments a\n          where a.user_id = p_user_id and a.concept_id = c.id)\n  ), last_seen as (\n    select c.topic_id, max(a.assigned_for) as seen_on\n      from public.daily_assignments a\n      join public.concepts c on c.id = a.concept_id and c.status = 'published'\n     where a.user_id = p_user_id group by c.topic_id\n  )\n  select pool.id, pool.topic_id into v_concept, v_topic\n    from pool left join last_seen on last_seen.topic_id = pool.topic_id\n   order by last_seen.seen_on asc nulls first, pool.unmet asc, pool.level asc, random()\n   limit 1;\n\n  if v_concept is not null then\n    begin\n      insert into public.daily_assignments(id, user_id, concept_id, assigned_for)\n      values (gen_random_uuid(), p_user_id, v_concept, v_today)\n      on conflict(user_id, assigned_for) do nothing returning id into v_assignment;\n    exception when unique_violation then\n      v_assignment := null;\n    end;\n    if v_assignment is not null then\n      perform public.edge_api_signal_supply(p_user_id, v_topic, p_low, p_reserve, p_active_days);\n      v_payload := public.edge_api_daily_payload(p_user_id, v_today);\n      if v_payload is not null then return v_payload; end if;\n    else\n      v_payload := public.edge_api_daily_payload(p_user_id, v_today);\n      if v_payload is not null then return v_payload; end if;\n    end if;\n  else\n    -- Request future supply in a followed topic without waiting for generation.\n    select ut.topic_id into v_topic from public.user_topics ut\n    join public.topics t on t.id = ut.topic_id and t.is_active\n    left join (\n      select c.topic_id, max(a.assigned_for) as seen_on\n      from public.daily_assignments a\n      join public.concepts c on c.id = a.concept_id and c.status = 'published'\n      where a.user_id = p_user_id group by c.topic_id\n    ) seen on seen.topic_id = ut.topic_id\n    where ut.user_id = p_user_id\n    order by seen.seen_on asc nulls first limit 1;\n    if v_topic is not null then\n      perform public.edge_api_signal_supply(p_user_id, v_topic, p_low, p_reserve, p_active_days);\n    end if;\n  end if;\n  return jsonb_build_object('assigned_for', v_today, 'reason', 'catalog_exhausted',\n    'detail', 'You have already been assigned every available concept.');\nend\n$function$\n"
    +        "definition": "CREATE OR REPLACE FUNCTION public.edge_api_daily(p_user_id uuid, p_low integer DEFAULT 10, p_reserve integer DEFAULT 60, p_active_days integer DEFAULT 90, p_today date DEFAULT NULL::date)\n RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $function$\ndeclare\n  v_today date;\n  v_timezone text;\n  v_concept uuid;\n  v_topic uuid;\n  v_assignment uuid;\n  v_payload jsonb;\nbegin\n  if p_low < 0 or p_low > 30 or p_reserve < 1 or p_reserve > 365\n     or p_active_days < 1 or p_active_days > 365 then\n    raise exception 'Invalid supply configuration';\n  end if;\n\n  -- Match the legacy-user safety net without restoring deliberately unfollowed topics.\n  insert into public.profiles(id, display_name)\n  select p_user_id, split_part(u.email, '@', 1)\n    from auth.users u where u.id = p_user_id\n  on conflict(id) do nothing;\n  insert into public.notification_preferences(user_id) values (p_user_id)\n  on conflict(user_id) do nothing;\n\n  select timezone into v_timezone\n    from public.profiles where id = p_user_id for update;\n  if v_timezone is null then\n    raise exception 'Profile unavailable';\n  end if;\n  v_today := coalesce(p_today, (now() at time zone v_timezone)::date);\n\n  if exists(select 1 from public.daily_reviews\n    where user_id = p_user_id and assigned_for = v_today) then\n    return jsonb_build_object('assigned_for', v_today, 'reason', 'catalog_exhausted',\n      'detail', 'You have already been assigned every available concept.');\n  end if;\n  if exists(select 1 from public.daily_assignments\n    where user_id = p_user_id and assigned_for = v_today) then\n    v_payload := public.edge_api_daily_payload(p_user_id, v_today);\n    if v_payload is not null then return v_payload; end if;\n    return jsonb_build_object('assigned_for', v_today, 'reason', 'catalog_exhausted',\n      'detail', 'You have already been assigned every available concept.');\n  end if;\n\n  if not exists(select 1 from public.user_topics ut\n    join public.topics t on t.id = ut.topic_id and t.is_active\n    where ut.user_id = p_user_id) then\n    return jsonb_build_object('assigned_for', v_today, 'reason', 'personalization_required',\n      'detail', 'Follow at least one topic to receive a daily concept.');\n  end if;\n\n  -- Move the most recent unfinished followed lesson, retaining its identity and timestamp.\n  with pending as (\n    select a.id from public.daily_assignments a\n    join public.concepts c on c.id = a.concept_id and c.status = 'published'\n    join public.topics t on t.id = c.topic_id and t.is_active\n    join public.subtopics s on s.id = c.subtopic_id and s.is_active\n    join public.user_topics ut on ut.topic_id = c.topic_id and ut.user_id = p_user_id\n    where a.user_id = p_user_id and a.completed_at is null and a.assigned_for < v_today\n    order by a.assigned_for desc, a.id limit 1\n  )\n  update public.daily_assignments a set assigned_for = v_today\n    from pending where a.id = pending.id returning a.concept_id into v_concept;\n  if v_concept is not null then\n    select topic_id into v_topic from public.concepts where id = v_concept;\n    perform public.edge_api_signal_supply(p_user_id, v_topic, p_low, p_reserve, p_active_days);\n    return public.edge_api_daily_payload(p_user_id, v_today);\n  end if;\n\n  with pool as (\n    select c.id, c.topic_id, coalesce(c.difficulty, 1) as level,\n      (select count(*) from jsonb_array_elements_text(\n        coalesce(c.curriculum->'prerequisites', '[]'::jsonb)) required(slug)\n        where not exists(select 1 from public.daily_assignments learned\n          join public.concepts prior on prior.id = learned.concept_id\n          where learned.user_id = p_user_id and learned.completed_at is not null\n            and prior.slug = required.slug)) as unmet\n      from public.concepts c\n      join public.topics t on t.id = c.topic_id and t.is_active\n      join public.subtopics s on s.id = c.subtopic_id and s.is_active\n      where c.status = 'published'\n        and exists(select 1 from public.user_topics ut\n          where ut.user_id = p_user_id and ut.topic_id = c.topic_id)\n        and not exists(select 1 from public.daily_assignments a\n          where a.user_id = p_user_id and a.concept_id = c.id)\n  ), last_seen as (\n    select c.topic_id, max(a.assigned_for) as seen_on\n      from public.daily_assignments a\n      join public.concepts c on c.id = a.concept_id and c.status = 'published'\n     where a.user_id = p_user_id group by c.topic_id\n  )\n  select pool.id, pool.topic_id into v_concept, v_topic\n    from pool left join last_seen on last_seen.topic_id = pool.topic_id\n   order by last_seen.seen_on asc nulls first, pool.unmet asc, pool.level asc, random()\n   limit 1;\n\n  if v_concept is not null then\n    begin\n      insert into public.daily_assignments(id, user_id, concept_id, assigned_for)\n      values (gen_random_uuid(), p_user_id, v_concept, v_today)\n      on conflict(user_id, assigned_for) do nothing returning id into v_assignment;\n    exception when unique_violation then\n      v_assignment := null;\n    end;\n    if v_assignment is not null then\n      perform public.edge_api_signal_supply(p_user_id, v_topic, p_low, p_reserve, p_active_days);\n      v_payload := public.edge_api_daily_payload(p_user_id, v_today);\n      if v_payload is not null then return v_payload; end if;\n    else\n      v_payload := public.edge_api_daily_payload(p_user_id, v_today);\n      if v_payload is not null then return v_payload; end if;\n    end if;\n  else\n    -- Request future supply in a followed topic without waiting for generation.\n    select ut.topic_id into v_topic from public.user_topics ut\n    join public.topics t on t.id = ut.topic_id and t.is_active\n    left join (\n      select c.topic_id, max(a.assigned_for) as seen_on\n      from public.daily_assignments a\n      join public.concepts c on c.id = a.concept_id and c.status = 'published'\n      where a.user_id = p_user_id group by c.topic_id\n    ) seen on seen.topic_id = ut.topic_id\n    where ut.user_id = p_user_id\n    order by seen.seen_on asc nulls first limit 1;\n    if v_topic is not null then\n      perform public.edge_api_signal_supply(p_user_id, v_topic, p_low, p_reserve, p_active_days);\n    end if;\n  end if;\n  return jsonb_build_object('assigned_for', v_today, 'reason', 'catalog_exhausted',\n    'detail', 'You have already been assigned every available concept.');\nend\n$function$\n"
           },
           "edge_api_daily_payload": {
             "definition": "CREATE OR REPLACE FUNCTION public.edge_api_daily_payload(p_user_id uuid, p_day date)\n RETURNS jsonb\n LANGUAGE sql\n STABLE\n SET search_path TO ''\nAS $function$\n  select jsonb_build_object(\n    'assigned_for', a.assigned_for, 'assigned_at', a.assigned_at,\n    'completed_at', a.completed_at, 'learned', a.completed_at is not null,\n    'outside_followed_topics', false,\n    'concept', jsonb_build_object(\n      'id', c.id, 'slug', c.slug, 'title', c.title, 'summary', c.summary,\n      'example', c.example, 'flashcard', c.flashcard,\n      'topic_slug', t.slug, 'topic_name', t.name,\n      'subtopic_slug', s.slug, 'subtopic_name', s.name,\n      'content_version', c.content_version,\n      'like_count', (select count(*)::integer from public.concept_interactions ci\n        where ci.concept_id = c.id and ci.liked_at is not null and ci.user_id <> p_user_id),\n      'review', (select jsonb_build_object(\n        'name', e.registered_name, 'reviewed_at', e.created_at,\n        'content_version', pub.content_version)\n        from public.editorial_publications pub\n        join public.editorial_revision_events e on e.id = pub.approval_id\n        where pub.concept_id = c.id and pub.content_version = c.content_version\n          and c.status = 'published'\n          and pub.snapshot = to_jsonb(c) - array['created_at','published_at','status']\n          and e.concept_id = c.id and e.action in ('approved', 'attested'))\n    )\n  )\n    from public.daily_assignments a\n    join public.concepts c on c.id = a.concept_id and c.status = 'published'\n    join public.topics t on t.id = c.topic_id\n    join public.subtopics s on s.id = c.subtopic_id\n   where a.user_id = p_user_id and a.assigned_for = p_day\n$function$\n"
    @@ -3411,19 +3413,19 @@
             "definition": "CREATE OR REPLACE FUNCTION public.edge_api_subtopic_progress(p_user_id uuid)\n RETURNS jsonb\n LANGUAGE sql\n STABLE\n SET search_path TO ''\nAS $function$\n  with catalog as (\n    select t.slug as topic_slug, t.name as topic_name,\n           s.id as subtopic_id, s.slug as subtopic_slug, s.name as subtopic_name,\n           array_agg(c.id order by c.id) as ids, count(*)::integer as available\n      from public.topics t\n      join public.subtopics s on s.topic_id = t.id and s.is_active\n      join public.concepts c on c.subtopic_id = s.id and c.status = 'published'\n     where t.is_active\n     group by t.slug, t.name, s.id, s.slug, s.name\n  ), progress as (\n    select catalog.*, count(done.concept_id)::integer as completed_concepts\n      from catalog\n      left join public.user_concept_completions done\n        on done.user_id = p_user_id and done.concept_id = any(catalog.ids)\n     group by catalog.topic_slug, catalog.topic_name, catalog.subtopic_id,\n              catalog.subtopic_slug, catalog.subtopic_name, catalog.ids, catalog.available\n  ), resolved as (\n    select progress.*,\n           (select event.id from public.user_subtopic_completions event\n             where event.user_id = p_user_id and event.subtopic_id = progress.subtopic_id\n               and event.catalog_signature =\n                 md5(array_to_string(progress.ids, ',')) ||\n                 md5('one-concept-subtopic-v1:' || array_to_string(progress.ids, ','))\n           ) as completion_id\n      from progress\n  )\n  select jsonb_build_object('items', coalesce(jsonb_agg(\n    jsonb_build_object(\n      'topic_slug', topic_slug, 'topic_name', topic_name,\n      'subtopic_slug', subtopic_slug, 'subtopic_name', subtopic_name,\n      'completed_concepts', completed_concepts, 'available_concepts', available,\n      'completed', completion_id is not null, 'completion_id', completion_id\n    ) order by topic_name, subtopic_name\n  ), '[]'::jsonb))\n    from resolved\n$function$\n"
           },
           "edge_api_subtopic_quiz": {
    -        "definition": "CREATE OR REPLACE FUNCTION public.edge_api_subtopic_quiz(p_user_id uuid, p_completion_id uuid)\n RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $function$\ndeclare\n  v_completion record;\n  v_quiz_id uuid;\n  v_questions jsonb;\n  v_public jsonb;\n  v_reviewed integer;\n  v_required integer;\nbegin\n  if not exists(select 1 from auth.users where id = p_user_id) then return null; end if;\n  with created as (\n    insert into public.profiles(id, display_name)\n    select p_user_id, split_part(u.email, '@', 1) from auth.users u\n     where u.id = p_user_id\n    on conflict(id) do nothing returning id\n  )\n  insert into public.user_topics(user_id, topic_id)\n  select created.id, t.id from created cross join public.topics t where t.is_active\n  on conflict do nothing;\n  insert into public.notification_preferences(user_id) values(p_user_id)\n  on conflict(user_id) do nothing;\n  perform 1 from public.profiles where id = p_user_id for update;\n  if not found then return null; end if;\n  select e.catalog_concept_ids, t.name as topic_name, s.name as subtopic_name\n    into v_completion from public.user_subtopic_completions e\n    join public.subtopics s on s.id = e.subtopic_id\n    join public.topics t on t.id = s.topic_id\n   where e.id = p_completion_id and e.user_id = p_user_id;\n  if not found then return jsonb_build_object('error', 'unavailable'); end if;\n  select id, questions into v_quiz_id, v_questions from public.subtopic_quizzes\n   where user_id = p_user_id and subtopic_completion_id = p_completion_id;\n  if v_quiz_id is null then\n    v_required := least(7, cardinality(v_completion.catalog_concept_ids));\n    with candidates as (\n      select c.id, c.slug, c.title, c.content_version, c.mcqs,\n        pg_catalog.sha256(convert_to(p_completion_id::text || ':select:' || c.id::text, 'UTF8')) as sort_key,\n        (get_byte(pg_catalog.sha256(convert_to(\n          p_completion_id::text || ':' || c.id::text, 'UTF8')), 0) * 256\n         + get_byte(pg_catalog.sha256(convert_to(\n          p_completion_id::text || ':' || c.id::text, 'UTF8')), 1)) % 3 as question_index\n        from public.concepts c\n       where c.id = any(v_completion.catalog_concept_ids)\n         and c.status = 'published' and jsonb_typeof(c.mcqs) = 'array'\n         and jsonb_array_length(c.mcqs) = 3\n       order by sort_key, c.id\n       limit v_required\n    )\n    select count(*)::integer,\n      coalesce(jsonb_agg(jsonb_build_object(\n        'id', id::text || ':' || content_version::text || ':' || question_index::text,\n        'concept_slug', slug, 'concept_title', title,\n        'content_version', content_version,\n        'question', mcqs -> question_index -> 'question',\n        'options', mcqs -> question_index -> 'options',\n        'correct_index', mcqs -> question_index -> 'correct_index'\n      ) order by sort_key, id), '[]'::jsonb)\n      into v_reviewed, v_questions from candidates;\n    if v_reviewed < v_required then\n      return jsonb_build_object('available', false, 'completion_id', p_completion_id,\n        'topic_name', v_completion.topic_name, 'subtopic_name', v_completion.subtopic_name,\n        'reviewed_concepts', v_reviewed, 'required_concepts', v_required,\n        'detail', 'This optional quiz will unlock when enough reviewed questions are available.');\n    end if;\n    insert into public.subtopic_quizzes(user_id, subtopic_completion_id, questions)\n    values(p_user_id, p_completion_id, v_questions) returning id into v_quiz_id;\n  end if;\n  select coalesce(jsonb_agg(q.value - 'correct_index' order by q.ordinality), '[]'::jsonb)\n    into v_public from jsonb_array_elements(v_questions) with ordinality as q(value, ordinality);\n  return jsonb_build_object('available', true, 'quiz_id', v_quiz_id,\n    'completion_id', p_completion_id, 'topic_name', v_completion.topic_name,\n    'subtopic_name', v_completion.subtopic_name, 'questions', v_public);\nend\n$function$\n"
    +        "definition": "CREATE OR REPLACE FUNCTION public.edge_api_subtopic_quiz(p_user_id uuid, p_completion_id uuid)\n RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $function$\ndeclare\n  v_completion record;\n  v_quiz_id uuid;\n  v_questions jsonb;\n  v_public jsonb;\n  v_reviewed integer;\n  v_required integer;\nbegin\n  if not exists(select 1 from auth.users where id = p_user_id) then return null; end if;\n  insert into public.profiles(id, display_name)\n  select p_user_id, split_part(u.email, '@', 1)\n    from auth.users u where u.id = p_user_id\n  on conflict(id) do nothing;\n  insert into public.notification_preferences(user_id) values(p_user_id)\n  on conflict(user_id) do nothing;\n  perform 1 from public.profiles where id = p_user_id for update;\n  if not found then return null; end if;\n  select e.catalog_concept_ids, t.name as topic_name, s.name as subtopic_name\n    into v_completion from public.user_subtopic_completions e\n    join public.subtopics s on s.id = e.subtopic_id\n    join public.topics t on t.id = s.topic_id\n   where e.id = p_completion_id and e.user_id = p_user_id;\n  if not found then return jsonb_build_object('error', 'unavailable'); end if;\n  select id, questions into v_quiz_id, v_questions from public.subtopic_quizzes\n   where user_id = p_user_id and subtopic_completion_id = p_completion_id;\n  if v_quiz_id is null then\n    v_required := least(7, cardinality(v_completion.catalog_concept_ids));\n    with candidates as (\n      select c.id, c.slug, c.title, c.content_version, c.mcqs,\n        pg_catalog.sha256(convert_to(p_completion_id::text || ':select:' || c.id::text, 'UTF8')) as sort_key,\n        (get_byte(pg_catalog.sha256(convert_to(\n          p_completion_id::text || ':' || c.id::text, 'UTF8')), 0) * 256\n         + get_byte(pg_catalog.sha256(convert_to(\n          p_completion_id::text || ':' || c.id::text, 'UTF8')), 1)) % 3 as question_index\n        from public.concepts c\n       where c.id = any(v_completion.catalog_concept_ids)\n         and c.status = 'published' and jsonb_typeof(c.mcqs) = 'array'\n         and jsonb_array_length(c.mcqs) = 3\n       order by sort_key, c.id\n       limit v_required\n    )\n    select count(*)::integer,\n      coalesce(jsonb_agg(jsonb_build_object(\n        'id', id::text || ':' || content_version::text || ':' || question_index::text,\n        'concept_slug', slug, 'concept_title', title,\n        'content_version', content_version,\n        'question', mcqs -> question_index -> 'question',\n        'options', mcqs -> question_index -> 'options',\n        'correct_index', mcqs -> question_index -> 'correct_index'\n      ) order by sort_key, id), '[]'::jsonb)\n      into v_reviewed, v_questions from candidates;\n    if v_reviewed < v_required then\n      return jsonb_build_object('available', false, 'completion_id', p_completion_id,\n        'topic_name', v_completion.topic_name, 'subtopic_name', v_completion.subtopic_name,\n        'reviewed_concepts', v_reviewed, 'required_concepts', v_required,\n        'detail', 'This optional quiz will unlock when enough reviewed questions are available.');\n    end if;\n    insert into public.subtopic_quizzes(user_id, subtopic_completion_id, questions)\n    values(p_user_id, p_completion_id, v_questions) returning id into v_quiz_id;\n  end if;\n  select coalesce(jsonb_agg(q.value - 'correct_index' order by q.ordinality), '[]'::jsonb)\n    into v_public from jsonb_array_elements(v_questions) with ordinality as q(value, ordinality);\n  return jsonb_build_object('available', true, 'quiz_id', v_quiz_id,\n    'completion_id', p_completion_id, 'topic_name', v_completion.topic_name,\n    'subtopic_name', v_completion.subtopic_name, 'questions', v_public);\nend\n$function$\n"
           },
           "edge_api_topics": {
             "definition": "CREATE OR REPLACE FUNCTION public.edge_api_topics(p_user_id uuid)\n RETURNS jsonb\n LANGUAGE sql\n STABLE\n SET search_path TO ''\nAS $function$\n  select coalesce(jsonb_agg(to_jsonb(topic_row) - 'sort_order'\n                            order by topic_row.sort_order, topic_row.name), '[]'::jsonb)\n  from (\n    select t.id, t.slug, t.name, t.description, t.sort_order,\n           count(c.id) filter (where c.status = 'published')::integer as concept_count,\n           exists (\n             select 1 from public.user_topics ut\n             where ut.user_id = p_user_id and ut.topic_id = t.id\n           ) as following\n      from public.topics t\n      left join public.concepts c on c.topic_id = t.id\n     where t.is_active\n     group by t.id, t.slug, t.name, t.description, t.sort_order\n  ) topic_row\n$function$\n"
           },
           "edge_api_weekly_quiz": {
    -        "definition": "CREATE OR REPLACE FUNCTION public.edge_api_weekly_quiz(p_user_id uuid)\n RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $function$\ndeclare\n  v_week date := current_date - (extract(isodow from current_date)::integer - 1);\n  v_quiz_id uuid;\n  v_questions jsonb;\n  v_public jsonb;\n  v_count integer;\nbegin\n  if not exists(select 1 from auth.users where id = p_user_id) then return null; end if;\n  with created as (\n    insert into public.profiles(id, display_name)\n    select p_user_id, split_part(u.email, '@', 1) from auth.users u\n     where u.id = p_user_id\n    on conflict(id) do nothing returning id\n  )\n  insert into public.user_topics(user_id, topic_id)\n  select created.id, t.id from created cross join public.topics t where t.is_active\n  on conflict do nothing;\n  insert into public.notification_preferences(user_id) values(p_user_id)\n  on conflict(user_id) do nothing;\n  perform 1 from public.profiles where id = p_user_id for update;\n  if not found then return null; end if;\n\n  select id, questions into v_quiz_id, v_questions\n    from public.weekly_quizzes where user_id = p_user_id and week_start = v_week;\n  if v_quiz_id is null then\n    with recent as (\n      select c.id, c.slug, c.title, c.content_version, c.mcqs,\n             max(a.completed_at) as completed_at\n        from public.daily_assignments a\n        join public.concepts c on c.id = a.concept_id\n       where a.user_id = p_user_id and a.completed_at is not null\n         and c.status = 'published' and jsonb_typeof(c.mcqs) = 'array'\n       group by c.id, c.slug, c.title, c.content_version, c.mcqs\n      having jsonb_array_length(c.mcqs) = 3\n       order by max(a.completed_at) desc, c.id\n       limit 7\n    ), selected as (\n      select recent.*,\n        (get_byte(pg_catalog.sha256(convert_to(\n          p_user_id::text || ':' || v_week::text || ':' || recent.id::text, 'UTF8')), 0) * 256\n         + get_byte(pg_catalog.sha256(convert_to(\n          p_user_id::text || ':' || v_week::text || ':' || recent.id::text, 'UTF8')), 1)) % 3\n        as question_index\n      from recent\n    )\n    select count(*)::integer,\n      coalesce(jsonb_agg(jsonb_build_object(\n        'id', id::text || ':' || content_version::text || ':' || question_index::text,\n        'concept_slug', slug, 'concept_title', title,\n        'content_version', content_version,\n        'question', mcqs -> question_index -> 'question',\n        'options', mcqs -> question_index -> 'options',\n        'correct_index', mcqs -> question_index -> 'correct_index'\n      ) order by completed_at desc, id), '[]'::jsonb)\n      into v_count, v_questions from selected;\n    if v_count < 7 then\n      return jsonb_build_object('available', false, 'week_start', v_week,\n        'required_concepts', 7, 'available_concepts', v_count,\n        'detail', 'Complete more concepts with reviewed questions to unlock this weekly quiz.');\n    end if;\n    insert into public.weekly_quizzes(user_id, week_start, questions)\n    values(p_user_id, v_week, v_questions) returning id into v_quiz_id;\n  end if;\n  select coalesce(jsonb_agg(q.value - 'correct_index' order by q.ordinality), '[]'::jsonb)\n    into v_public from jsonb_array_elements(v_questions) with ordinality as q(value, ordinality);\n  return jsonb_build_object('available', true, 'quiz_id', v_quiz_id,\n    'week_start', v_week, 'questions', v_public);\nend\n$function$\n"
    +        "definition": "CREATE OR REPLACE FUNCTION public.edge_api_weekly_quiz(p_user_id uuid)\n RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $function$\ndeclare\n  v_week date := current_date - (extract(isodow from current_date)::integer - 1);\n  v_quiz_id uuid;\n  v_questions jsonb;\n  v_public jsonb;\n  v_count integer;\nbegin\n  if not exists(select 1 from auth.users where id = p_user_id) then return null; end if;\n  insert into public.profiles(id, display_name)\n  select p_user_id, split_part(u.email, '@', 1)\n    from auth.users u where u.id = p_user_id\n  on conflict(id) do nothing;\n  insert into public.notification_preferences(user_id) values(p_user_id)\n  on conflict(user_id) do nothing;\n  perform 1 from public.profiles where id = p_user_id for update;\n  if not found then return null; end if;\n\n  select id, questions into v_quiz_id, v_questions\n    from public.weekly_quizzes where user_id = p_user_id and week_start = v_week;\n  if v_quiz_id is null then\n    with recent as (\n      select c.id, c.slug, c.title, c.content_version, c.mcqs,\n             max(a.completed_at) as completed_at\n        from public.daily_assignments a\n        join public.concepts c on c.id = a.concept_id\n       where a.user_id = p_user_id and a.completed_at is not null\n         and c.status = 'published' and jsonb_typeof(c.mcqs) = 'array'\n       group by c.id, c.slug, c.title, c.content_version, c.mcqs\n      having jsonb_array_length(c.mcqs) = 3\n       order by max(a.completed_at) desc, c.id\n       limit 7\n    ), selected as (\n      select recent.*,\n        (get_byte(pg_catalog.sha256(convert_to(\n          p_user_id::text || ':' || v_week::text || ':' || recent.id::text, 'UTF8')), 0) * 256\n         + get_byte(pg_catalog.sha256(convert_to(\n          p_user_id::text || ':' || v_week::text || ':' || recent.id::text, 'UTF8')), 1)) % 3\n        as question_index\n      from recent\n    )\n    select count(*)::integer,\n      coalesce(jsonb_agg(jsonb_build_object(\n        'id', id::text || ':' || content_version::text || ':' || question_index::text,\n        'concept_slug', slug, 'concept_title', title,\n        'content_version', content_version,\n        'question', mcqs -> question_index -> 'question',\n        'options', mcqs -> question_index -> 'options',\n        'correct_index', mcqs -> question_index -> 'correct_index'\n      ) order by completed_at desc, id), '[]'::jsonb)\n      into v_count, v_questions from selected;\n    if v_count < 7 then\n      return jsonb_build_object('available', false, 'week_start', v_week,\n        'required_concepts', 7, 'available_concepts', v_count,\n        'detail', 'Complete more concepts with reviewed questions to unlock this weekly quiz.');\n    end if;\n    insert into public.weekly_quizzes(user_id, week_start, questions)\n    values(p_user_id, v_week, v_questions) returning id into v_quiz_id;\n  end if;\n  select coalesce(jsonb_agg(q.value - 'correct_index' order by q.ordinality), '[]'::jsonb)\n    into v_public from jsonb_array_elements(v_questions) with ordinality as q(value, ordinality);\n  return jsonb_build_object('available', true, 'quiz_id', v_quiz_id,\n    'week_start', v_week, 'questions', v_public);\nend\n$function$\n"
           },
           "enqueue_editorial_notification": {
             "definition": "CREATE OR REPLACE FUNCTION public.enqueue_editorial_notification()\n RETURNS trigger\n LANGUAGE plpgsql\n SET search_path TO 'pg_catalog'\nAS $function$\nbegin\n  if new.assigned_to is not null and new.review_assigned_at is not null\n     and new.status in ('draft','pending_review') then\n    insert into public.editorial_notification_outbox(revision_id,recipient_id,epoch,ordinal)\n      values(new.id,new.assigned_to,new.notification_epoch,0) on conflict do nothing;\n  end if;\n  return new;\nend $function$\n"
           },
           "handle_new_user": {
    -        "definition": "CREATE OR REPLACE FUNCTION public.handle_new_user()\n RETURNS trigger\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO 'public'\nAS $function$\nbegin\n  insert into public.profiles (id, display_name)\n  values (\n    new.id,\n    coalesce(new.raw_user_meta_data ->> 'display_name', split_part(new.email, '@', 1))\n  )\n  on conflict (id) do nothing;\n\n  insert into public.notification_preferences (user_id)\n  values (new.id)\n  on conflict (user_id) do nothing;\n\n  -- Follow every active topic by default, matching the app's current behaviour.\n  insert into public.user_topics (user_id, topic_id)\n  select new.id, t.id from public.topics t where t.is_active\n  on conflict do nothing;\n\n  return new;\nend;\n$function$\n"
    +        "definition": "CREATE OR REPLACE FUNCTION public.handle_new_user()\n RETURNS trigger\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $function$\nbegin\n  insert into public.profiles (id, display_name)\n  values (\n    new.id,\n    coalesce(new.raw_user_meta_data ->> 'display_name', split_part(new.email, '@', 1))\n  )\n  on conflict (id) do nothing;\n\n  insert into public.notification_preferences (user_id)\n  values (new.id)\n  on conflict (user_id) do nothing;\n\n  return new;\nend;\n$function$\n"
           },
           "prevent_editorial_evidence_mutation": {
             "definition": "CREATE OR REPLACE FUNCTION public.prevent_editorial_evidence_mutation()\n RETURNS trigger\n LANGUAGE plpgsql\n SET search_path TO 'pg_catalog'\nAS $function$\nbegin\n  raise exception 'Editorial evidence is append-only';\nend $function$\n"
    diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
    index 2d717bd6..26311104 100644
    --- a/backend/tests/conftest.py
    +++ b/backend/tests/conftest.py
    @@ -145,7 +145,7 @@ async def session(sessionmaker_for_test):
     
     @pytest_asyncio.fixture
     async def user(session):
    -    """A fresh user, bootstrapped by the same trigger production relies on."""
    +    """An existing learner with explicit follows for content-selection tests."""
         from sqlalchemy import text
     
         user_id = uuid.uuid4()
    @@ -153,6 +153,11 @@ async def user(session):
             text("insert into auth.users (id, email) values (:id, :email)"),
             {"id": user_id, "email": f"{user_id}@example.invalid"},
         )
    +    await session.execute(text("""
    +        insert into public.user_topics(user_id, topic_id)
    +        select :id, id from public.topics where is_active
    +        on conflict do nothing
    +    """), {"id": user_id})
         await session.commit()
         return user_id
     
    diff --git a/backend/tests/test_edge_api_daily.py b/backend/tests/test_edge_api_daily.py
    index 8c393d45..3abfb528 100644
    --- a/backend/tests/test_edge_api_daily.py
    +++ b/backend/tests/test_edge_api_daily.py
    @@ -17,6 +17,26 @@ def _json(value):
         return json.loads(value) if isinstance(value, str) else value
     
     
    +@pytest.mark.asyncio
    +async def test_new_account_starts_without_follows_and_can_opt_in(session):
    +    fresh = uuid.uuid4()
    +    await session.execute(text("insert into auth.users(id,email) values (:id,:email)"),
    +                          {"id": fresh, "email": f"{fresh}@example.invalid"})
    +    await session.commit()
    +    assert await session.scalar(text("select count(*) from public.profiles where id=:id"), {"id": fresh}) == 1
    +    assert await session.scalar(text("select count(*) from public.notification_preferences where user_id=:id"), {"id": fresh}) == 1
    +    assert await session.scalar(text("select count(*) from public.user_topics where user_id=:id"), {"id": fresh}) == 0
    +    before = _json(await session.scalar(DAILY, {"uid": fresh, "day": DAY}))
    +    assert before["reason"] == "personalization_required"
    +    assert await session.scalar(text("select count(*) from public.daily_assignments where user_id=:id"), {"id": fresh}) == 0
    +
    +    saved = _json(await session.scalar(text("select public.edge_api_set_topics(:id,:slugs,true,:day)"),
    +                                       {"id": fresh, "slugs": ["software-engineering"], "day": DAY}))
    +    assert saved["followed_topics"] == ["software-engineering"]
    +    selected = _json(await session.scalar(DAILY, {"uid": fresh, "day": DAY}))
    +    assert selected["concept"]["topic_slug"] == "software-engineering"
    +
    +
     @pytest.mark.asyncio
     async def test_daily_requires_active_follow_and_never_chooses_other_subjects(session, user):
         await session.execute(text("delete from public.user_topics where user_id = :uid"), {"uid": user})
    diff --git a/backend/tests/test_edge_api_opt_in_bootstrap.py b/backend/tests/test_edge_api_opt_in_bootstrap.py
    new file mode 100644
    index 00000000..add7f28f
    --- /dev/null
    +++ b/backend/tests/test_edge_api_opt_in_bootstrap.py
    @@ -0,0 +1,45 @@
    +"""Every Edge first-request path keeps a trigger-skipped account opt-in."""
    +
    +import json
    +import uuid
    +from datetime import date
    +
    +import pytest
    +from sqlalchemy import text
    +
    +
    +DAY = date(2026, 10, 15)
    +
    +
    +@pytest.mark.parametrize("function, params, expected", [
    +    ("edge_api_daily", "(:uid, 10, 60, 90, :day)", "personalization_required"),
    +    ("edge_api_achievements", "(:uid)", "items"),
    +    ("edge_api_weekly_quiz", "(:uid)", "available"),
    +    ("edge_api_subtopic_quiz", "(:uid, :completion)", "unavailable"),
    +])
    +async def test_edge_first_request_does_not_follow_all_topics(session, function, params, expected):
    +    user_id = uuid.uuid4()
    +    await session.execute(text("alter table auth.users disable trigger on_auth_user_created"))
    +    try:
    +        await session.execute(text("insert into auth.users(id,email) values (:id,:email)"),
    +                              {"id": user_id, "email": f"{user_id}@example.invalid"})
    +    finally:
    +        await session.execute(text("alter table auth.users enable trigger on_auth_user_created"))
    +    await session.commit()
    +
    +    assert await session.scalar(text("select count(*) from public.profiles where id=:id"), {"id": user_id}) == 0
    +    result = await session.scalar(text(f"select public.{function}{params}"),
    +                                  {"uid": user_id, "day": DAY, "completion": uuid.uuid4()})
    +    result = json.loads(result) if isinstance(result, str) else result
    +    if expected == "personalization_required":
    +        assert result["reason"] == expected
    +    elif expected == "unavailable":
    +        assert result["error"] == expected
    +    elif expected == "available":
    +        assert result["available"] is False
    +    else:
    +        assert isinstance(result[expected], list)
    +
    +    assert await session.scalar(text("select count(*) from public.profiles where id=:id"), {"id": user_id}) == 1
    +    assert await session.scalar(text("select count(*) from public.notification_preferences where user_id=:id"), {"id": user_id}) == 1
    +    assert await session.scalar(text("select count(*) from public.user_topics where user_id=:id"), {"id": user_id}) == 0
    diff --git a/backend/tests/test_writes.py b/backend/tests/test_writes.py
    index 0a8c7d78..aeb36b6f 100644
    --- a/backend/tests/test_writes.py
    +++ b/backend/tests/test_writes.py
    @@ -157,9 +157,8 @@ async def test_state_aggregates_everything_the_app_needs(session, user):
     async def test_bootstrap_creates_everything_when_the_trigger_did_not(session):
         """The safety net must work for a user with no profile at all.
     
    -    ensure_bootstrapped runs profile, preferences, and follows in one statement
    -    for latency, and follows carry a foreign key to profiles — so this asserts
    -    the FK is satisfied within that single statement rather than assuming it.
    +    Missing-trigger accounts still start without topic follows; an old default
    +    here would silently bypass the opt-in signup migration.
         """
         import uuid as _uuid
     
    @@ -187,7 +186,9 @@ async def test_bootstrap_creates_everything_when_the_trigger_did_not(session):
             text("select count(*) from public.notification_preferences where user_id = :u"), {"u": user_id})
         follows = await session.scalar(
             text("select count(*) from public.user_topics where user_id = :u"), {"u": user_id})
    -    assert (profiles, prefs, follows) == (1, 1, 5)
    +    assert (profiles, prefs, follows) == (1, 1, 0)
    +    daily = await get_or_create_daily(session, user_id, today=DAY)
    +    assert daily.status == "personalization_required"
     
     
     async def test_bootstrap_does_not_resurrect_unfollowed_topics(session, user):
    diff --git a/docs/CODEBASE_MAP.md b/docs/CODEBASE_MAP.md
    index e4f239fd..a0348419 100644
    --- a/docs/CODEBASE_MAP.md
    +++ b/docs/CODEBASE_MAP.md
    @@ -110,8 +110,9 @@ packages, diffs, history, comments, checklist decisions and safe corrections.
     subject batch at a time. `services/legacy_generation.py` builds a grounded
     complete-card candidate and `legacy_enrichment_worker.py` stages it privately
     through the scheduled `pool_topup.py` run; only a later human approval publishes.
    -`MarkdownText.tsx` safely renders lesson writing and `LessonEditor` shows a live
    -preview; identifiers and configuration fields remain plain text.
    +`MarkdownText.tsx` safely renders lesson writing, with `markdownCode.tsx` adding
    +bounded Prism token highlighting to fenced code; `LessonEditor` shows a live
    +preview. Identifiers and configuration fields remain plain text.
     `api.ts` and `useCommand.ts` preserve exact operation retries and stale-token
     failures. Migration 0037 adds audited review deadlines. Queue totals are computed
     with page results in one statement; published rows require matching exact-version
    @@ -248,7 +249,7 @@ inside a root stack, with a concept-detail modal above them.
     | `WeeklyQuizScreen.tsx` | Optional server-backed weekly quiz: eligibility progress, seven reviewed questions, icon-marked accessible answer selection, result feedback and reattempts. |
     | `SubtopicQuizzesScreen.tsx` / `SubtopicQuizScreen.tsx` | Profile-linked optional quizzes for completed subtopics, frozen reviewed questions, icon-marked accessible answer selection, retries, and prior-score history. |
     | `ProfileScreen.tsx` | Task-grouped account hub: profile/privacy, learning, topics, preferences, library and support. It keeps clear reminder prerequisites and immediate per-control queued reminder writes. |
    -| `EditProfileScreen.tsx` / `services/profilePhotoPicker.ts` | Name and bio editing, camera/library photo preview and explicit save, account-bound Android picker recovery, and confirmed avatar writes. |
    +| `EditProfileScreen.tsx` / `services/profilePhotoPicker.ts` / `services/profilePhotoPreparation.ts` | Keyboard-aware name and bio editing, camera/library photo preview and explicit save, account-bound Android picker recovery, bounded JPEG preparation, and confirmed avatar writes. |
     | `ProfileSharingScreen.tsx` / `ProfilePublishReviewSheet.tsx` / `PublicProfileLink.tsx` | Opt-in, independently selected sharing fields, a private review-before-publish step, explicit unpublish confirmation, native share/local QR, and uncached incoming public-profile view. |
     | `PersonalizationScreen.tsx` | Server topic catalog and follow controls through `useTopics`. |
     | `SavedScreen.tsx` | Recent/cached saved concepts, older metadata pagination, search/category filters, and detail navigation. |
    @@ -258,7 +259,8 @@ inside a root stack, with a concept-detail modal above them.
     
     All screens live in `mobile/src/screens/`. Reusable presentation in
     `mobile/src/components/` covers lesson cards/actions, category/follow controls,
    -reviewed lesson and quiz Markdown through the pure-JavaScript `MarkdownText`,
    +reviewed lesson and quiz Markdown through `MarkdownText` and its bounded Prism
    +code tokenizer,
     like counts, streak/flame visuals, buttons, skeletons, the offline banner,
     `SearchField` and `CollectionConceptRow` for compact accessible Saved/History collections,
     `UnavailableState` (animated offline/retry UI), and the What's New card.
    @@ -338,6 +340,8 @@ share `hooks/useRefreshControl.tsx` for native pull gestures and refresh buttons
     - `hooks/useTopics.ts`, `services/topicsApi.ts`, and `topicStore.ts` share the
       cached dynamic topic catalog. Follow changes enter the same durable outbox as
       other actions; queued choices override stale server responses until replay.
    +  A catalog-cache write failure cannot roll back an already queued choice;
    +  the Following screen shows pending sync and local save/refresh errors.
       Both the catalog and full-concept cache participate in account cleanup.
     - `services/notifications.ts` handles permissions, Android channel setup, Expo
       tokens, timezone sync, preference caching, and deregistration before sign-out.
    @@ -523,6 +527,8 @@ enforce one daily assignment and no concept repeats per user. RLS adds isolation
     | `0014_content_operations.sql` | Worker heartbeat and deduplicated condition state. |
     | `0015_revision_generation_claims.sql` | Durable claims for correction drafting. |
     | `0017_topic_subtopics.sql`–`0019_subtopic_topic_cleanup.sql` | Parent-scoped subtopics, explicit classification of published/planned content, category integrity, and safe empty-topic cleanup. |
    +| `0131_opt_in_followed_topics.sql` | New Auth users start with no follows; existing `user_topics` rows remain untouched. Apply through the normal migration gate before releasing the matching mobile UI. |
    +| `0132_opt_in_edge_bootstrap.sql` | Daily/state, achievements, weekly quiz, and subtopic quiz Edge first-request paths create only profile/preferences when the Auth trigger was skipped. They never seed topic follows; existing follows remain untouched. |
     
     Migrations 0001–0015 are recorded in `migrations/applied.txt`. The owner applied
     0011–0015 during 1.9.0 release preparation, and a separate read-only production
    diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md
    index cc003623..1e44bc64 100644
    --- a/docs/WORK_LOG.md
    +++ b/docs/WORK_LOG.md
    @@ -7,6 +7,20 @@ claims as completed work.
     
     ## Current status
     
    +### Combined learner fixes #454–#457 — 2026-10-24
    +
    +- Branch `codex/fix-454-457-product-polish` starts from current `develop` at `dae3d95`. The owner requested one PR covering Markdown readability and code samples (#454), opt-in followed topics (#455), keyboard-safe profile editing (#456), and gallery/camera avatar uploads (#457). Target is `develop`; this task does not authorize a release or PR merge.
    +- Root causes: the signup trigger auto-followed every active topic; local fallback selection treated zero follows as all topics. The mobile Markdown component lacked fenced-code highlighting and horizontal overflow. The profile form did not account for the soft keyboard. Photo upload forwarded picker bytes and MIME unchanged to an API that accepts JPEG/PNG/WebP at 5 MB, so HEIC or large inputs could fail before a real network error occurred.
    +- Commits `6079d4c`, `2df020d`, `0723f01`, and `3f5518c` add keyboard-aware profile scrolling, immutable migration 0131 with opt-in follows and a matching Home empty state, Prism-backed Markdown in the existing learner and reviewer/owner renderers, and bounded JPEG preparation with separate preparation/HTTP/network errors. Existing account follows and guest demo defaults remain intact. A new APK with `expo-image-manipulator` and an appropriate native runtime is required before the photo code can be sent as an OTA to installed phones; no release or production migration is performed by this PR.
    +- Follow-up `2cab6b7` makes unsafe Markdown links display only their text label after a narrow-screen visual review. [PR #458](https://github.com/Coding-Moves/one-concept/pull/458) targets `develop` and links all four issues for closure only on merge; it remains open for review. No production change has been made by opening it.
    +- Second review found that the legacy FastAPI fallback bootstrap still auto-followed every topic if the Auth signup trigger had been skipped. Commit `be9008a` removes that remaining default and checks that a trigger-skipped account gets profile/preferences, zero follows, and `personalization_required` on its first daily call. Both focused disposable PostgreSQL bootstrap tests passed; Python compilation and diff checks passed.
    +- Third review traced the Supabase API path and found the same old default in four Edge first-request functions. Commit `36cef3c` adds immutable migration 0132 to make daily/state, achievements, weekly quiz, and subtopic quiz bootstrap only profile/preferences. Four trigger-skipped account cases plus the schema contract passed in disposable PostgreSQL (5 tests); no existing topic rows are changed. Apply 0131 and 0132 in order before releasing the matching app behavior.
    +- Fourth review found that an outbox write could succeed while the separate topic catalog cache write failed, causing the UI to roll back a choice that would still sync. The follow controls now keep the durable choice, report a failed outbox write with a tap-again retry path, and distinguish pending offline/online sync from a saved choice. A cached catalog refresh failure also remains visible with a retry button. Topic-store tests cover last-unfollow persistence, cache failure after enqueue, and failed enqueue rollback; the mobile typecheck and 101 Node tests passed locally. Mocked browser checks passed in light and dark themes, plus a new end-to-end first-follow → lesson → refresh → last-unfollow → empty-state refresh flow.
    +- Fifth review found that the new `expo-image-manipulator` native dependency still used runtime `1.10.8`. A production OTA could reach older binaries without that module. The PR now bumps both the app version and native runtime to `1.10.16` and adds the matching What's New card. The next release must build and distribute a new APK for this runtime; older installed binaries remain on their compatible runtime. This PR does not publish the release or build the APK.
    +- Sixth review reproduced a topic refresh racing the durable last-unfollow enqueue. The refresh could publish an older server follow before the queued empty selection was visible, leaving Home on the wrong topic state. `TopicStore.load()` now waits for in-flight local writes before applying the server list. A regression test failed on the prior code and passes with the fix.
    +- Seventh review found that a refresh started during a failing local enqueue could advance the store revision before the failed toggle rolled back; with the server offline, the unsaved choice stayed visible. The refresh now waits for a stable local write tail before advancing its revision. The new offline failure regression failed before this correction and passes afterward, while the stale-server and account-reset cases still pass.
    +- Verification: disposable PostgreSQL schema/signup tests 2 passed, daily-selection tests 8 passed, and followed-topic/selection tests 20 passed; mobile Node suite 98 passed; admin Vitest suite 22 passed and production build passed with dummy public config; mobile TypeScript and web export passed; browser checks passed for personalization, Markdown, and photo upload in light/dark themes, including large-source JPEG conversion, 413/415/transport/503 recovery and short-viewport profile input visibility. All three hosted PR checks passed on review head `4d35102` ([run 40082357408](https://github.com/Coding-Moves/one-concept/actions/runs/40082357408)). Native keyboard, HEIC, and camera behavior on physical devices remain for release QA.
    +
     ### Register protected production workflows on the default branch — 2026-10-21
     
     - After release PR #434 merged into `main` at `abc2dcf`, the new public Netlify pages returned HTML with the expected security headers while their API setting still pointed to Railway. GitHub did not list the new production deploy or inactive-Cron workflow because the repository default branch is `develop` and those workflow files existed only on `main`. GitHub requires a `workflow_dispatch` file on the default branch even when the run targets another ref.
    diff --git a/mobile/app.config.js b/mobile/app.config.js
    index 71501d16..07077688 100644
    --- a/mobile/app.config.js
    +++ b/mobile/app.config.js
    @@ -18,7 +18,7 @@ module.exports = {
         name: 'One Concept',
         slug: 'one-concept',
         owner: 'coding-moves',
    -    version: '1.10.15',
    +    version: '1.10.16',
         orientation: 'portrait',
         icon: './assets/icon.png',
         userInterfaceStyle: 'automatic',
    @@ -37,7 +37,7 @@ module.exports = {
           predictiveBackGestureEnabled: false,
         },
         // Updated Expo native packages require a new APK before this runtime receives updates.
    -    runtimeVersion: '1.10.8',
    +    runtimeVersion: '1.10.16',
         updates: {
           checkAutomatically: 'ON_LOAD',
           fallbackToCacheTimeout: 0,
    diff --git a/mobile/package-lock.json b/mobile/package-lock.json
    index a050b1b9..183f2ed7 100644
    --- a/mobile/package-lock.json
    +++ b/mobile/package-lock.json
    @@ -21,6 +21,7 @@
             "expo-constants": "~57.0.21",
             "expo-device": "~57.0.2",
             "expo-font": "~57.0.4",
    +        "expo-image-manipulator": "57.0.21",
             "expo-image-picker": "~57.0.20",
             "expo-notifications": "~57.0.23",
             "expo-secure-store": "~57.0.4",
    @@ -28,6 +29,7 @@
             "expo-status-bar": "~57.0.1",
             "expo-updates": "~57.0.26",
             "markdown-it": "15.0.3",
    +        "prismjs": "^1.30.0",
             "qrcode-generator": "2.0.4",
             "react": "19.2.8",
             "react-dom": "19.2.8",
    @@ -39,6 +41,7 @@
           },
           "devDependencies": {
             "@types/markdown-it": "^14.2.0",
    +        "@types/prismjs": "^1.26.5",
             "@types/react": "~19.2.2",
             "typescript": "~7.0.2"
           }
    @@ -2253,6 +2256,13 @@
             "undici-types": "~8.3.0"
           }
         },
    +    "node_modules/@types/prismjs": {
    +      "version": "1.26.5",
    +      "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz",
    +      "integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==",
    +      "dev": true,
    +      "license": "MIT"
    +    },
         "node_modules/@types/react": {
           "version": "19.2.18",
           "devOptional": true,
    @@ -3669,6 +3679,18 @@
             "expo": "*"
           }
         },
    +    "node_modules/expo-image-manipulator": {
    +      "version": "57.0.21",
    +      "resolved": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-57.0.21.tgz",
    +      "integrity": "sha512-NZeNvZxBNf/PK97Df5zORmtqRR/LovkA+lHdRLm1IJFiVKjs4xr+Ib6/jWhDxn04VOX3TJswCQyu4SCxkgMe4Q==",
    +      "license": "MIT",
    +      "dependencies": {
    +        "expo-image-loader": "~57.0.1"
    +      },
    +      "peerDependencies": {
    +        "expo": "*"
    +      }
    +    },
         "node_modules/expo-image-picker": {
           "version": "57.0.20",
           "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-57.0.20.tgz",
    @@ -5692,6 +5714,15 @@
             "url": "https://github.com/chalk/ansi-styles?sponsor=1"
           }
         },
    +    "node_modules/prismjs": {
    +      "version": "1.30.0",
    +      "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
    +      "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
    +      "license": "MIT",
    +      "engines": {
    +        "node": ">=6"
    +      }
    +    },
         "node_modules/proc-log": {
           "version": "4.2.0",
           "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz",
    diff --git a/mobile/package.json b/mobile/package.json
    index d0529972..dc8aa2c5 100644
    --- a/mobile/package.json
    +++ b/mobile/package.json
    @@ -16,6 +16,7 @@
         "expo-constants": "~57.0.21",
         "expo-device": "~57.0.2",
         "expo-font": "~57.0.4",
    +    "expo-image-manipulator": "57.0.21",
         "expo-image-picker": "~57.0.20",
         "expo-notifications": "~57.0.23",
         "expo-secure-store": "~57.0.4",
    @@ -23,6 +24,7 @@
         "expo-status-bar": "~57.0.1",
         "expo-updates": "~57.0.26",
         "markdown-it": "15.0.3",
    +    "prismjs": "^1.30.0",
         "qrcode-generator": "2.0.4",
         "react": "19.2.8",
         "react-dom": "19.2.8",
    @@ -34,6 +36,7 @@
       },
       "devDependencies": {
         "@types/markdown-it": "^14.2.0",
    +    "@types/prismjs": "^1.26.5",
         "@types/react": "~19.2.2",
         "typescript": "~7.0.2"
       },
    diff --git a/mobile/src/components/MarkdownText.tsx b/mobile/src/components/MarkdownText.tsx
    index 8281559a..7baecda0 100644
    --- a/mobile/src/components/MarkdownText.tsx
    +++ b/mobile/src/components/MarkdownText.tsx
    @@ -1,21 +1,26 @@
     import { useMemo } from 'react';
     import type { ReactNode } from 'react';
     import MarkdownIt from 'markdown-it';
    -import { Linking, StyleSheet, Text, View } from 'react-native';
    +import { Linking, ScrollView, StyleSheet, Text, View } from 'react-native';
     import type { StyleProp, TextStyle } from 'react-native';
    +import type { Token as CodeToken } from 'prismjs';
     import { useTheme } from '../context/ThemeContext';
     import { scaleFont } from '../theme';
    +import { codeTokens } from './markdownCode';
     
    -type MdNode = { type: string; content: string; attrs: [string, string | number][] | null; children: MdNode[] };
    -type MdToken = { type: string; nesting: number; content: string; attrs: [string, string | number][] | null; children: MdToken[] | null };
    +type MdNode = { type: string; tag: string; content: string; info: string; attrs: [string, string | number][] | null; children: MdNode[] };
    +type MdToken = { type: string; tag: string; nesting: number; content: string; info?: string; attrs: [string, string | number][] | null; children: MdToken[] | null };
     const parser = new MarkdownIt({ html: false, linkify: false, typographer: false });
    +// Parse link labels even when their destination is unsafe; the renderer below
    +// then presents only the label instead of leaking Markdown punctuation.
    +parser.validateLink = () => true;
     
     function tree(tokens: readonly MdToken[]): MdNode[] {
    -  const root: MdNode = { type: 'root', content: '', attrs: null, children: [] };
    +  const root: MdNode = { type: 'root', tag: '', content: '', info: '', attrs: null, children: [] };
       const stack = [root];
       for (const token of tokens) {
         if (token.nesting === -1) { if (stack.length > 1) stack.pop(); continue; }
    -    const node: MdNode = { type: token.type, content: token.content, attrs: token.attrs, children: token.children ? tree(token.children) : [] };
    +    const node: MdNode = { type: token.type, tag: token.tag, content: token.content, info: token.info ?? '', attrs: token.attrs, children: token.children ? tree(token.children) : [] };
         stack[stack.length - 1].children.push(node);
         if (token.nesting === 1) stack.push(node);
       }
    @@ -40,12 +45,25 @@ export function markdownPlainText(value: string) {
     }
     
     /** Native Text renderer for lesson Markdown; HTML and remote Markdown images never render. */
    -export function MarkdownText({ value, style, inline = false, interactiveLinks = true }: { value: string; style?: StyleProp; inline?: boolean; interactiveLinks?: boolean }) {
    +export function MarkdownText({ value, style, inline = false, interactiveLinks = true }: { value: unknown; style?: StyleProp; inline?: boolean; interactiveLinks?: boolean }) {
       const { colors } = useTheme();
    -  const nodes = useMemo(() => tree(inline ? parser.parseInline(value || '', {}) : parser.parse(value || '', {})), [value, inline]);
    +  const source = typeof value === 'string' ? value : '';
    +  const nodes = useMemo(() => tree(inline ? parser.parseInline(source, {}) : parser.parse(source, {})), [source, inline]);
       const base = { color: colors.text, fontSize: scaleFont(15), lineHeight: scaleFont(23) };
       const code = { fontFamily: 'monospace', backgroundColor: colors.surfaceSubtle, color: colors.text } as const;
     
    +  function renderCode(items: (string | CodeToken)[], prefix = ''): ReactNode[] {
    +    return items.map((item, index) => {
    +      if (typeof item === 'string') return item;
    +      const tone = item.type === 'comment' || item.type === 'prolog' ? colors.textMuted
    +        : item.type === 'keyword' || item.type === 'boolean' ? colors.primary
    +        : item.type === 'string' || item.type === 'number' ? colors.success
    +        : colors.text;
    +      const content = typeof item.content === 'string' ? item.content : renderCode(Array.isArray(item.content) ? item.content : [item.content], `${prefix}${index}-`);
    +      return {content};
    +    });
    +  }
    +
       function renderInline(items: MdNode[]): ReactNode[] {
         return items.map((node, index) => {
           const key = `${node.type}-${index}`;
    @@ -59,7 +77,7 @@ export function MarkdownText({ value, style, inline = false, interactiveLinks =
             case 's_open': return {renderInline(node.children)};
             case 'link_open': {
               const url = safeLink(String(node.attrs?.find(([name]) => name === 'href')?.[1] ?? ''));
    -          return  { void Linking.openURL(url); } : undefined}>{renderInline(node.children)};
    +          return  { void Linking.openURL(url); } : undefined}>{renderInline(node.children)};
             }
             case 'image': return node.content || node.children.map(child => child.content).join('');
             case 'html_inline': return node.content;
    @@ -73,8 +91,12 @@ export function MarkdownText({ value, style, inline = false, interactiveLinks =
           const key = `${node.type}-${index}`;
           switch (node.type) {
             case 'paragraph_open': return {renderInline(node.children.flatMap(child => child.type === 'inline' ? child.children : [child]))};
    -        case 'heading_open': return {renderInline(node.children.flatMap(child => child.type === 'inline' ? child.children : [child]))};
    -        case 'fence': case 'code_block': return {node.content};
    +        case 'heading_open': {
    +          const level = Number(node.tag.match(/^h([1-6])$/)?.[1] ?? 2);
    +          const fontSize = level <= 2 ? 21 : level <= 4 ? 18 : 16;
    +          return {renderInline(node.children.flatMap(child => child.type === 'inline' ? child.children : [child]))};
    +        }
    +        case 'fence': case 'code_block': return {renderCode(codeTokens(node.content, node.info))};
             case 'blockquote_open': return {renderBlock(node.children)};
             case 'bullet_list_open': case 'ordered_list_open': {
               const start = Number(node.attrs?.find(([name]) => name === 'start')?.[1] ?? 1);
    diff --git a/mobile/src/components/markdownCode.ts b/mobile/src/components/markdownCode.ts
    new file mode 100644
    index 00000000..b23db489
    --- /dev/null
    +++ b/mobile/src/components/markdownCode.ts
    @@ -0,0 +1,24 @@
    +import Prism from 'prismjs';
    +import 'prismjs/components/prism-javascript';
    +import 'prismjs/components/prism-typescript';
    +import 'prismjs/components/prism-json';
    +import 'prismjs/components/prism-python';
    +import 'prismjs/components/prism-bash';
    +import 'prismjs/components/prism-sql';
    +import 'prismjs/components/prism-css';
    +import 'prismjs/components/prism-markup';
    +
    +const aliases: Record = {
    +  js: 'javascript', jsx: 'javascript', javascript: 'javascript',
    +  ts: 'typescript', tsx: 'typescript', typescript: 'typescript',
    +  json: 'json', py: 'python', python: 'python',
    +  sh: 'bash', shell: 'bash', bash: 'bash',
    +  sql: 'sql', css: 'css', html: 'markup', xml: 'markup',
    +};
    +
    +export function codeTokens(source: string, info: string) {
    +  const language = aliases[info.trim().split(/\s+/)[0].toLowerCase()];
    +  const grammar = language && Prism.languages[language];
    +  // Very large generated lessons should remain readable without expensive tokenization.
    +  return grammar && source.length <= 20_000 ? Prism.tokenize(source, grammar) : [source];
    +}
    diff --git a/mobile/src/context/ProgressContext.tsx b/mobile/src/context/ProgressContext.tsx
    index bb7de252..1d109639 100644
    --- a/mobile/src/context/ProgressContext.tsx
    +++ b/mobile/src/context/ProgressContext.tsx
    @@ -29,7 +29,7 @@ export interface ProgressContextValue {
       loading: boolean;
       refresh: () => Promise;
       updateProfile: (input: { displayName?: string; bio?: string; avatarPreset?: string }) => Promise;
    -  uploadAvatar: (uri: string, mimeType: string) => Promise;
    +  uploadAvatar: (jpeg: Blob) => Promise;
       removeAvatar: () => Promise;
       pausedSyncCount: number;
       retrySync: () => Promise;
    @@ -55,10 +55,9 @@ export interface ProgressContextValue {
     
     const ProgressContext = createContext(null);
     
    -/** Concepts eligible for daily selection: followed topics, or all if none followed. */
    +/** Empty follows mean no eligible concepts. Guest demo defaults are explicit. */
     function eligibleConcepts(progress: ProgressState): Concept[] {
    -  const pool = CONCEPTS.filter((c) => progress.followedTopics.includes(c.category));
    -  return pool.length > 0 ? pool : CONCEPTS;
    +  return CONCEPTS.filter((c) => progress.followedTopics.includes(c.category));
     }
     
     interface Props {
    @@ -286,9 +285,9 @@ export function ProgressProvider({ children, repository: override }: Props) {
         if (!userId || !repository.updateProfile) throw new Error('Sign in to edit your profile');
         await confirmProfileWrite(() => repository.updateProfile!(input, userId));
       }, [confirmProfileWrite, repository, userId]);
    -  const uploadAvatar = useCallback(async (uri: string, mimeType: string) => {
    +  const uploadAvatar = useCallback(async (jpeg: Blob) => {
         if (!userId || !repository.uploadAvatar) throw new Error('Sign in to add a photo');
    -    await confirmProfileWrite(() => repository.uploadAvatar!(uri, mimeType, userId));
    +    await confirmProfileWrite(() => repository.uploadAvatar!(jpeg, userId));
       }, [confirmProfileWrite, repository, userId]);
       const removeAvatar = useCallback(async () => {
         if (!userId || !repository.removeAvatar) throw new Error('Sign in to remove a photo');
    diff --git a/mobile/src/data/whatsNew.ts b/mobile/src/data/whatsNew.ts
    index bca2b99e..6f2439fc 100644
    --- a/mobile/src/data/whatsNew.ts
    +++ b/mobile/src/data/whatsNew.ts
    @@ -21,6 +21,14 @@ export interface WhatsNewEntry {
     }
     
     export const WHATS_NEW: WhatsNewEntry[] = [
    +  {
    +    version: '1.10.16',
    +    highlights: [
    +      'Choose the topics you want to follow before receiving personalized lessons.',
    +      'Read lesson examples and code more clearly, including on narrow screens.',
    +      'Edit your profile with the keyboard in view and prepare gallery or camera photos for upload.',
    +    ],
    +  },
       {
         version: '1.10.15',
         highlights: [
    diff --git a/mobile/src/hooks/useTopics.ts b/mobile/src/hooks/useTopics.ts
    index 85839e04..aa252d31 100644
    --- a/mobile/src/hooks/useTopics.ts
    +++ b/mobile/src/hooks/useTopics.ts
    @@ -1,10 +1,13 @@
    -import { useCallback, useEffect, useState, useSyncExternalStore } from 'react';
    +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
     import { useAuth } from '../context/AuthContext';
     import { fetchTopics, ServerTopic, topicStore } from '../services/topicsApi';
    +import { pending as queuedMutations, subscribeQueue } from '../services/mutationQueue';
     
     export interface Topics {
       loading: boolean;
    -  error: unknown;
    +  loadError: unknown;
    +  saveError: boolean;
    +  syncPending: boolean;
       retry: () => void;
       topics: ServerTopic[];
       /** Follow/unfollow one topic. The PUT sends every currently-followed slug
    @@ -19,10 +22,32 @@ export function useTopics(): Topics {
       const userId = session?.user?.id ?? null;
       const topics = useSyncExternalStore(topicStore.subscribe, topicStore.getSnapshot);
       const [loading, setLoading] = useState(true);
    -  const [error, setError] = useState(null);
    +  const [loadError, setLoadError] = useState(null);
    +  const [saveError, setSaveError] = useState(false);
    +  const [syncPending, setSyncPending] = useState(false);
    +  const saveRevision = useRef(0);
       const [attempt, setAttempt] = useState(0);
       const retry = useCallback(() => setAttempt((value) => value + 1), []);
     
    +  useEffect(() => {
    +    saveRevision.current += 1;
    +    setSaveError(false);
    +    return () => { saveRevision.current += 1; };
    +  }, [userId]);
    +
    +  useEffect(() => {
    +    if (!userId) { setSyncPending(false); return; }
    +    let active = true;
    +    const update = () => {
    +      void queuedMutations().then(entries => {
    +        if (active) setSyncPending(entries.some(entry => entry.kind === 'topics'));
    +      }).catch(() => {});
    +    };
    +    update();
    +    const unsubscribe = subscribeQueue(update);
    +    return () => { active = false; unsubscribe(); };
    +  }, [userId]);
    +
       useEffect(() => {
         if (!userId) {
           setLoading(false);
    @@ -30,10 +55,10 @@ export function useTopics(): Topics {
         }
         let cancelled = false;
         setLoading(true);
    -    setError(null);
    +    setLoadError(null);
         fetchTopics()
           .catch((cause: unknown) => {
    -        if (!cancelled) setError(cause);
    +        if (!cancelled) setLoadError(cause);
           })
           .finally(() => {
             if (!cancelled) setLoading(false);
    @@ -45,10 +70,14 @@ export function useTopics(): Topics {
     
       const toggle = useCallback(
         (slug: string) => {
    -      topicStore.toggle(slug).catch((cause: unknown) => setError(cause));
    +      const revision = ++saveRevision.current;
    +      setSaveError(false);
    +      topicStore.toggle(slug).catch(() => {
    +        if (revision === saveRevision.current) setSaveError(true);
    +      });
         },
         []
       );
     
    -  return { loading: loading && topics.length === 0, error, retry, topics, toggle };
    +  return { loading: loading && topics.length === 0, loadError, saveError, syncPending, retry, topics, toggle };
     }
    diff --git a/mobile/src/screens/EditProfileScreen.tsx b/mobile/src/screens/EditProfileScreen.tsx
    index dd749394..e0f8a660 100644
    --- a/mobile/src/screens/EditProfileScreen.tsx
    +++ b/mobile/src/screens/EditProfileScreen.tsx
    @@ -1,8 +1,9 @@
     import * as ImagePicker from 'expo-image-picker';
     import { useNavigation } from '@react-navigation/native';
     import { useEffect, useRef, useState } from 'react';
    -import { ActivityIndicator, Alert, Image, Pressable, ScrollView, Text, TextInput, View } from 'react-native';
    -import { ApiError } from '../api/client';
    +import { ActivityIndicator, Alert, Image, Keyboard, KeyboardAvoidingView, Platform, Pressable, ScrollView, Text, TextInput, View } from 'react-native';
    +import { useSafeAreaInsets } from 'react-native-safe-area-context';
    +import { ApiConfigurationError, ApiError } from '../api/client';
     import { ProfileAvatar, AVATAR_PRESETS, AvatarPreset } from '../components/ProfileAvatar';
     import { ScreenHeader } from '../components/ScreenHeader';
     import { useAuth } from '../context/AuthContext';
    @@ -10,21 +11,27 @@ import { useProgress } from '../context/ProgressContext';
     import { useTheme } from '../context/ThemeContext';
     import { useOnline } from '../context/ConnectivityContext';
     import { beginProfilePhotoPick, claimProfilePhotoPick } from '../services/profilePhotoPicker';
    +import { prepareProfilePhoto, ProfilePhotoPreparationError } from '../services/profilePhotoPreparation';
     import { normalizeProfileName } from '../services/profileName';
     
     function photoSaveError(error: unknown): string {
    +  if (error instanceof ProfilePhotoPreparationError) return error.message;
    +  if (error instanceof ApiConfigurationError) return 'Install the latest app update before saving a photo.';
       if (error instanceof ApiError) {
    -    if (error.status === 0) return 'No connection. Connect and try saving your photo again.';
    -    if (error.status === 413) return 'Choose a photo smaller than 5 MB.';
    -    if (error.status === 415 || error.status === 422) return 'This photo could not be used. Try a smaller JPEG, PNG, or WebP image.';
    -    if (error.status === 503) return 'Photo saving is unavailable right now. Try again later or choose a built-in avatar.';
    -    if (error.status === 401) return 'Your account changed. Open Edit profile again to choose a photo.';
    +    if (error.status === 0) return 'Could not reach the photo service. Check your connection and try again.';
    +    if (error.status === 400 || error.status === 422) return 'This photo could not be processed. Choose another image.';
    +    if (error.status === 401 || error.status === 403) return 'Your session needs attention. Sign in again, then retry your photo.';
    +    if (error.status === 413) return 'The photo is too large. Choose another image.';
    +    if (error.status === 415) return 'This photo format could not be used. Choose another image.';
    +    if (error.status === 429) return 'Too many photo requests. Please try again shortly.';
    +    if (error.status >= 500) return 'Photo saving is unavailable right now. Try again later or choose a built-in avatar.';
       }
       return 'Could not save your photo. Try again or choose another image.';
     }
     
     export function EditProfileScreen() {
       const navigation = useNavigation();
    +  const insets = useSafeAreaInsets();
       const userId = useAuth().session?.user.id;
       const { progress, updateProfile, uploadAvatar, removeAvatar } = useProgress();
       const { colors } = useTheme();
    @@ -40,9 +47,22 @@ export function EditProfileScreen() {
       const busy = useRef(false);
       const active = useRef(true);
       const currentUserId = useRef(userId);
    +  const scroll = useRef(null);
    +  const focusedFieldY = useRef(null);
    +  const nameFieldY = useRef(0);
    +  const bioFieldY = useRef(0);
       currentUserId.current = userId;
     
       useEffect(() => { active.current = true; return () => { active.current = false; }; }, []);
    +  useEffect(() => {
    +    if (Platform.OS === 'web') return;
    +    const subscription = Keyboard.addListener('keyboardDidShow', () => {
    +      if (focusedFieldY.current !== null) {
    +        scroll.current?.scrollTo({ y: Math.max(0, focusedFieldY.current - 72), animated: true });
    +      }
    +    });
    +    return () => subscription.remove();
    +  }, []);
       useEffect(() => {
         if (!userId) return;
         let current = true;
    @@ -114,11 +134,9 @@ export function EditProfileScreen() {
     
       const savePhoto = async () => {
         if (!photo || busy.current) return;
    -    if (!online) { setPhotoError('Connect to the internet to save this photo.'); return; }
    -    if (photo.fileSize && photo.fileSize > 5 * 1024 * 1024) { setPhotoError('Choose a photo smaller than 5 MB.'); return; }
         busy.current = true; setSaving(true); setSavingPhoto(true); setPhotoError(''); setPhotoSuccess(''); setMessage('');
         try {
    -      await uploadAvatar(photo.uri, photo.mimeType || 'image/jpeg');
    +      await uploadAvatar(await prepareProfilePhoto(photo));
           if (active.current) { setPhoto(null); setPhotoSuccess('Profile photo saved.'); }
         } catch (error) {
           if (active.current) setPhotoError(photoSaveError(error));
    @@ -139,7 +157,17 @@ export function EditProfileScreen() {
         }
       };
     
    -  return 
    +  const focusField = (y: number) => {
    +    focusedFieldY.current = y;
    +    // If the keyboard is already open while moving between fields, there is
    +    // no second keyboardDidShow event to bring the new input into view.
    +    setTimeout(() => {
    +      if (focusedFieldY.current === y) scroll.current?.scrollTo({ y: Math.max(0, y - 72), animated: true });
    +    }, 80);
    +  };
    +
    +  return 
    +    
          navigation.goBack()} style={{ minHeight: 44, justifyContent: 'center' }}>← Back
         
         
    @@ -155,23 +183,23 @@ export function EditProfileScreen() {
           Preview your photo
           
           The centered area shown here will be your profile photo.
    -       void savePhoto()} style={{ minHeight: 52, alignSelf: 'stretch', justifyContent: 'center', borderRadius: 16, backgroundColor: colors.primary, opacity: saving || !online ? 0.55 : 1 }}>
    +       void savePhoto()} style={{ minHeight: 52, alignSelf: 'stretch', justifyContent: 'center', borderRadius: 16, backgroundColor: colors.primary, opacity: saving ? 0.55 : 1 }}>
             {savingPhoto ? 'Saving photo…' : 'Save photo'}
           
            void pick(false)} style={{ minHeight: 44, justifyContent: 'center' }}>Choose another photo
            { setPhoto(null); setPhotoError(''); }} style={{ minHeight: 44, justifyContent: 'center' }}>Cancel photo change
           {savingPhoto ?  : null}
    -      {!online ? Connect to save your photo. : null}
           {photoError ? {photoError} : null}
          : photoError ? {photoError} : null}
         Or choose an avatar
         {AVATAR_PRESETS.map(([id]) =>  void choosePreset(id as AvatarPreset)}>)}
         Preferred name
    -    
    +     { nameFieldY.current = event.nativeEvent.layout.y; }} onFocus={() => focusField(nameFieldY.current)} onBlur={() => { focusedFieldY.current = null; }} editable={!saving} autoCapitalize="words" style={{ color: colors.text, backgroundColor: colors.surface, borderRadius: 16, padding: 16, fontSize: 18, minHeight: 52 }} />
         Short bio ({bio.length}/160)
    -    
    +     { bioFieldY.current = event.nativeEvent.layout.y; }} onFocus={() => focusField(bioFieldY.current)} onBlur={() => { focusedFieldY.current = null; }} editable={!saving} maxLength={160} multiline style={{ color: colors.text, backgroundColor: colors.surface, borderRadius: 16, padding: 16, minHeight: 84, textAlignVertical: 'top' }} placeholder="What are you learning?" placeholderTextColor={colors.textMuted} />
         {!online && Connect to save your profile.}
         {saving ? 'Saving…' : 'Save profile'}
         {message ? {message} : null}
    -  ;
    +    
    +  ;
     }
    diff --git a/mobile/src/screens/PersonalizationScreen.tsx b/mobile/src/screens/PersonalizationScreen.tsx
    index 98771719..85f1f8de 100644
    --- a/mobile/src/screens/PersonalizationScreen.tsx
    +++ b/mobile/src/screens/PersonalizationScreen.tsx
    @@ -15,7 +15,7 @@ import { scaleIcon, scaleFont, spacing, ThemeColors, typography } from '../theme
     
     export function PersonalizationScreen() {
       const navigation = useNavigation();
    -  const { loading, error, retry, topics, toggle } = useTopics();
    +  const { loading, loadError, saveError, syncPending, retry, topics, toggle } = useTopics();
       const online = useOnline();
       const refreshUI = useRefreshControl('subjects', fetchTopics);
       const { colors } = useTheme();
    @@ -46,12 +46,28 @@ export function PersonalizationScreen() {
             day’s concept.
           
     
    +      {saveError ? (
    +        
    +          Your topic change was not saved. Tap Follow or Following again to retry.
    +        
    +      ) : null}
    +      {syncPending ? (
    +        
    +          {online ? 'Saving your topic choices…' : 'Offline — your topic choices will sync when connected.'}
    +        
    +      ) : null}
    +      {loadError && topics.length > 0 ? (
    +        
    +          Could not refresh topics. Showing your saved list. Tap to retry.
    +        
    +      ) : null}
    +
           {loading ? (
             Loading topics…
    -      ) : error && topics.length === 0 ? (
    +      ) : loadError && topics.length === 0 ? (
             
    @@ -135,4 +151,16 @@ const createStyles = (colors: ThemeColors) =>
           fontSize: scaleFont(13),
           color: colors.textMuted,
         },
    +    errorText: {
    +      fontSize: scaleFont(14),
    +      lineHeight: scaleFont(20),
    +      color: colors.text,
    +    },
    +    retryButton: {
    +      minHeight: 44,
    +      justifyContent: 'center',
    +      padding: spacing.md,
    +      borderRadius: 12,
    +      backgroundColor: colors.dangerSurface,
    +    },
       });
    diff --git a/mobile/src/screens/StatsScreen.tsx b/mobile/src/screens/StatsScreen.tsx
    index aeffcbb4..57a2a0dc 100644
    --- a/mobile/src/screens/StatsScreen.tsx
    +++ b/mobile/src/screens/StatsScreen.tsx
    @@ -52,7 +52,7 @@ function serverCategoryProgress(
     
     export function StatsScreen() {
       const { loading, progress, streaks, refresh } = useProgress();
    -  const { loading: topicsLoading, topics, error, retry } = useTopics();
    +  const { loading: topicsLoading, topics, loadError: error, retry } = useTopics();
       const { session } = useAuth();
       const online = useOnline();
       const { colors } = useTheme();
    diff --git a/mobile/src/screens/TodayScreen.tsx b/mobile/src/screens/TodayScreen.tsx
    index 23859b33..8283d1d5 100644
    --- a/mobile/src/screens/TodayScreen.tsx
    +++ b/mobile/src/screens/TodayScreen.tsx
    @@ -2,7 +2,7 @@ import { useRefreshControl } from '../hooks/useRefreshControl';
     import { useNavigation, NavigationProp, NavigatorScreenParams } from '@react-navigation/native';
     import type { ProfileStackParamList } from './ProfileScreen';
     import { Ionicons } from '@expo/vector-icons';
    -import { useMemo } from 'react';
    +import { useMemo, useSyncExternalStore } from 'react';
     import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
     import { ConceptActions } from '../components/ConceptActions';
     import { ConceptCard } from '../components/ConceptCard';
    @@ -19,6 +19,7 @@ import { useProgress } from '../context/ProgressContext';
     import { useTheme } from '../context/ThemeContext';
     import { toConcept } from '../services/dailyApi';
     import { greetingFor } from '../services/greeting';
    +import { topicStore } from '../services/topicsApi';
     import { scaleIcon, scaleFont, radius, shadows, spacing, ThemeColors } from '../theme';
     
     export function TodayScreen() {
    @@ -39,6 +40,7 @@ export function TodayScreen() {
       const { colors, mode, toggle } = useTheme();
       const styles = useMemo(() => createStyles(colors), [colors]);
       const refreshUI = useRefreshControl('today', refresh);
    +  const topics = useSyncExternalStore(topicStore.subscribe, topicStore.getSnapshot);
     
       const navigation = useNavigation}>>();
       const explore = () => navigation.navigate('Profile', {screen: 'Personalization'});
    @@ -59,7 +61,13 @@ export function TodayScreen() {
       const done = review ? outcome.payload.learned : learnedToday || (!!serverConcept && hasLearned(serverConcept.id));
       const loading = localLoading;
       const exhausted = outcome?.status === 'exhausted';
    -  const personalizationRequired = outcome?.status === 'personalization_required';
    +  // The learner can unfollow the last topic while today's older assignment is
    +  // still pinned. Hide that assignment immediately; future selection already
    +  // refuses to create another one without a followed topic.
    +  const noFollowedTopics = !!session && (topics.length > 0
    +    ? !topics.some(topic => topic.following)
    +    : progress.followedTopics.length === 0);
    +  const personalizationRequired = noFollowedTopics || outcome?.status === 'personalization_required';
       const greeting = greetingFor(new Date(), progress.timezone, progress.displayName);
       const offline = (outcome?.status === 'ok'
         || outcome?.status === 'review'
    @@ -115,7 +123,7 @@ export function TodayScreen() {
                 
               ) : null}
     
    -          {outsideTopics ? (
    +          {outsideTopics && !personalizationRequired ? (
                 
                   
                   
    @@ -124,7 +132,7 @@ export function TodayScreen() {
                 
               ) : null}
     
    -          {exhausted ? (
    +          {exhausted && !personalizationRequired ? (
                 
                   
                   
    @@ -133,7 +141,7 @@ export function TodayScreen() {
                 
               ) : null}
     
    -          {review ? (
    +          {review && !personalizationRequired ? (
                 
                   
                   
    @@ -150,9 +158,9 @@ export function TodayScreen() {
                   
                     
                   
    -              Choose what you want to learn
    +              Follow your interests to start learning
                   
    -                Follow one or more topics to shape your next daily concept. Your previous lessons and streak stay safe.
    +                Choose one or more topics for your daily concepts. Your previous lessons and streak stay safe.
                   
                   
                 
    @@ -170,7 +178,7 @@ export function TodayScreen() {
                 />
               ) : null}
     
    -          {concept && (done ? (
    +          {concept && !personalizationRequired && (done ? (
                 <>
                   
                     
    diff --git a/mobile/src/services/localProgressRepository.ts b/mobile/src/services/localProgressRepository.ts
    index d002e8b8..56d2b3e9 100644
    --- a/mobile/src/services/localProgressRepository.ts
    +++ b/mobile/src/services/localProgressRepository.ts
    @@ -1,6 +1,6 @@
     import { Category, ProgressState } from '../types';
     import { ProgressRepository } from './progressRepository';
    -import { EMPTY_PROGRESS, loadProgress, saveProgress } from './storage';
    +import { GUEST_PROGRESS, loadProgress, saveProgress } from './storage';
     
     function toggleInList(list: string[], id: string): string[] {
       return list.includes(id) ? list.filter((x) => x !== id) : [...list, id];
    @@ -14,7 +14,7 @@ function toggleInList(list: string[], id: string): string[] {
      * same class becomes the offline cache behind the HTTP repository.
      */
     export class LocalProgressRepository implements ProgressRepository {
    -  private cache: ProgressState = EMPTY_PROGRESS;
    +  private cache: ProgressState = GUEST_PROGRESS;
     
       async load(): Promise {
         this.cache = await loadProgress();
    diff --git a/mobile/src/services/profilePhotoPreparation.ts b/mobile/src/services/profilePhotoPreparation.ts
    new file mode 100644
    index 00000000..80370e05
    --- /dev/null
    +++ b/mobile/src/services/profilePhotoPreparation.ts
    @@ -0,0 +1,50 @@
    +import type { ImagePickerAsset } from 'expo-image-picker';
    +
    +const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
    +const ATTEMPTS = [
    +  { longestSide: 1600, quality: 0.82 },
    +  { longestSide: 1024, quality: 0.68 },
    +  { longestSide: 768, quality: 0.55 },
    +] as const;
    +
    +export class ProfilePhotoPreparationError extends Error {
    +  constructor(message: string) {
    +    super(message);
    +    this.name = 'ProfilePhotoPreparationError';
    +  }
    +}
    +
    +/** The API accepts JPEG bytes up to 5 MB, regardless of picker metadata. */
    +export async function prepareProfilePhoto(asset: ImagePickerAsset): Promise {
    +  if (!asset.uri || !Number.isFinite(asset.width) || !Number.isFinite(asset.height)
    +      || asset.width <= 0 || asset.height <= 0) {
    +    throw new ProfilePhotoPreparationError('This photo could not be read. Choose another image.');
    +  }
    +
    +  for (const { longestSide, quality } of ATTEMPTS) {
    +    try {
    +      // Load only when saving: installed binaries need the native module in
    +      // their next APK, but older builds should still open the profile screen.
    +      const { ImageManipulator, SaveFormat } = await import('expo-image-manipulator');
    +      const context = ImageManipulator.manipulate(asset.uri);
    +      const longer = Math.max(asset.width, asset.height);
    +      if (longer > longestSide) {
    +        if (asset.width >= asset.height) context.resize({ width: longestSide });
    +        else context.resize({ height: longestSide });
    +      }
    +      const image = await context.renderAsync();
    +      const saved = await image.saveAsync({ format: SaveFormat.JPEG, compress: quality });
    +      const response = await fetch(saved.uri);
    +      const bytes = await response.blob();
    +      // Never claim JPEG if the native encoder returned something else.
    +      const signature = new Uint8Array(await bytes.slice(0, 3).arrayBuffer());
    +      if (signature[0] !== 0xff || signature[1] !== 0xd8 || signature[2] !== 0xff) {
    +        throw new Error('native encoder did not return JPEG');
    +      }
    +      if (bytes.size > 0 && bytes.size <= MAX_UPLOAD_BYTES) return bytes;
    +    } catch {
    +      throw new ProfilePhotoPreparationError('This phone could not prepare that photo. Try a different JPEG or PNG image.');
    +    }
    +  }
    +  throw new ProfilePhotoPreparationError('This photo is still too large. Choose another image.');
    +}
    diff --git a/mobile/src/services/progressRepository.ts b/mobile/src/services/progressRepository.ts
    index 8208799b..f2834fa7 100644
    --- a/mobile/src/services/progressRepository.ts
    +++ b/mobile/src/services/progressRepository.ts
    @@ -13,7 +13,7 @@ import { Category, ProgressState } from '../types';
     export interface ProgressRepository {
       load(): Promise;
       updateProfile?(input: { displayName?: string; bio?: string; avatarPreset?: string }, userId: string): Promise;
    -  uploadAvatar?(uri: string, mimeType: string, userId: string): Promise;
    +  uploadAvatar?(jpeg: Blob, userId: string): Promise;
       removeAvatar?(userId: string): Promise;
     
       /** Last known state without touching the network, or null if none exists.
    diff --git a/mobile/src/services/remoteProgressRepository.ts b/mobile/src/services/remoteProgressRepository.ts
    index f3dbab9e..fe808d2a 100644
    --- a/mobile/src/services/remoteProgressRepository.ts
    +++ b/mobile/src/services/remoteProgressRepository.ts
    @@ -195,10 +195,9 @@ export class RemoteProgressRepository implements ProgressRepository {
         return this.fromState(payload, epoch);
       }
     
    -  async uploadAvatar(uri: string, mimeType: string, userId: string): Promise {
    +  async uploadAvatar(jpeg: Blob, userId: string): Promise {
         const epoch = this.epoch;
    -    const response = await fetch(uri); const body = await response.blob();
    -    const payload = await this.request(epoch, '/v1/me/avatar?compact=true', { method: 'PUT', rawBody: body, contentType: mimeType, expectedUserId: userId });
    +    const payload = await this.request(epoch, '/v1/me/avatar?compact=true', { method: 'PUT', rawBody: jpeg, contentType: 'image/jpeg', expectedUserId: userId });
         return this.fromState(payload, epoch);
       }
     
    diff --git a/mobile/src/services/storage.ts b/mobile/src/services/storage.ts
    index b6fa3952..76035298 100644
    --- a/mobile/src/services/storage.ts
    +++ b/mobile/src/services/storage.ts
    @@ -6,15 +6,20 @@ const STORAGE_KEY = 'one-concept/progress/v1';
     export const EMPTY_PROGRESS: ProgressState = {
       learned: [],
       assignment: null,
    -  followedTopics: [...CATEGORIES],
    +  // A missing account state must never mean "follow everything".
    +  followedTopics: [],
       likes: [],
       bookmarks: [],
     };
     
    +// The signed-out, on-device demo remains explicit so existing guest behavior
    +// and old guest records are not silently migrated to a different preference.
    +export const GUEST_PROGRESS: ProgressState = { ...EMPTY_PROGRESS, followedTopics: [...CATEGORIES] };
    +
     export async function loadProgress(): Promise {
       try {
         const raw = await AsyncStorage.getItem(STORAGE_KEY);
    -    if (!raw) return EMPTY_PROGRESS;
    +    if (!raw) return GUEST_PROGRESS;
         const parsed = JSON.parse(raw) as Partial;
         return {
           learned: Array.isArray(parsed.learned) ? parsed.learned : [],
    @@ -28,7 +33,7 @@ export async function loadProgress(): Promise {
         };
       } catch {
         // Corrupt or unreadable state: start fresh rather than crash on launch.
    -    return EMPTY_PROGRESS;
    +    return GUEST_PROGRESS;
       }
     }
     
    diff --git a/mobile/src/services/topicStore.ts b/mobile/src/services/topicStore.ts
    index e8557e55..2e2cf8e0 100644
    --- a/mobile/src/services/topicStore.ts
    +++ b/mobile/src/services/topicStore.ts
    @@ -36,6 +36,17 @@ export class TopicStore {
       }
     
       async load(): Promise {
    +    const epoch = this.epoch;
    +    // Settle local writes before claiming a refresh revision. Claiming it
    +    // earlier would suppress a failed toggle's rollback while offline.
    +    // A toggle can arrive during the wait, so repeat until the write tail is
    +    // stable before reading the queued intent and older server response.
    +    for (;;) {
    +      const writes = this.writes;
    +      await writes;
    +      if (epoch !== this.epoch) return this.topics;
    +      if (writes === this.writes) break;
    +    }
         const revision = ++this.revision;
         const cached = await this.deps.read();
         const overlay = (rows: ServerTopic[], slugs?: string[]) => slugs
    @@ -70,7 +81,9 @@ export class TopicStore {
           // All follows use the durable queue, even online. A single replay path
           // preserves order when connectivity changes during rapid taps.
           await this.deps.enqueue(next.filter(topic => topic.following).map(topic => topic.slug));
    -      if (epoch === this.epoch) await this.deps.write(next);
    +      // The queued intent is authoritative. A catalog-cache write failure
    +      // must not undo a choice that the sync loop will still send later.
    +      if (epoch === this.epoch) await this.deps.write(next).catch(() => {});
         });
         this.writes = write.catch(() => {});
         return write.catch(error => {
    diff --git a/mobile/tests/markdown.browser.cjs b/mobile/tests/markdown.browser.cjs
    new file mode 100644
    index 00000000..320a36ba
    --- /dev/null
    +++ b/mobile/tests/markdown.browser.cjs
    @@ -0,0 +1,73 @@
    +const { chromium, expect } = require(process.env.PLAYWRIGHT_TEST_MODULE || 'playwright/test');
    +const assert = require('node:assert/strict');
    +const fs = require('node:fs');
    +const http = require('node:http');
    +const path = require('node:path');
    +
    +const root = process.argv[2];
    +if (!root || !fs.existsSync(path.join(root, 'index.html'))) throw Error('Pass the exported web directory');
    +const version = require('../app.config.js').expo.version;
    +const today = new Date().toISOString().slice(0, 10);
    +const concept = {
    +  id: '33333333-3333-4333-8333-333333333333', slug: 'markdown-lesson',
    +  title: 'Markdown lesson',
    +  summary: '# A heading\n\nA **bold** paragraph with [unsafe](javascript:alert(1)).\n\n```js\nconst longValue = "012345678901234567890123456789012345678901234567890123456789";\n```',
    +  example: '> A quoted idea\n\n- First item\n- Second item',
    +  topic_slug: 'computer-science', topic_name: 'Computer Science', content_version: 1, like_count: 0,
    +};
    +const session = { access_token: 'fixture', refresh_token: 'fixture-refresh', token_type: 'bearer', expires_in: 864000, expires_at: Math.floor(Date.now() / 1000) + 864000, user: { id: '11111111-1111-1111-1111-111111111111', email: 'fixture@example.invalid', aud: 'authenticated', role: 'authenticated', app_metadata: {}, user_metadata: {}, created_at: '2026-01-01T00:00:00Z' } };
    +const state = { display_name: 'Reader', timezone: 'UTC', today, followed_topics: ['computer-science'], learned: [], likes: [], bookmarks: [], saved: [], stats: { current: 0, longest: 0, total_learned: 0, total_reviews: 0 }, assignment_slug: concept.slug, daily: { assigned_for: today, assigned_at: today + 'T08:00:00Z', learned: false, completed_at: null, outside_followed_topics: false, concept } };
    +const server = http.createServer((req, res) => {
    +  const relative = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
    +  const file = path.join(root, relative === '/' ? 'index.html' : relative);
    +  try {
    +    res.setHeader('Content-Type', ({ '.html': 'text/html', '.js': 'application/javascript', '.ttf': 'font/ttf', '.png': 'image/png' })[path.extname(file)] || 'application/octet-stream');
    +    res.end(fs.readFileSync(file));
    +  } catch { res.statusCode = 404; res.end(); }
    +});
    +
    +(async () => {
    +  await new Promise(resolve => server.listen(4781, '127.0.0.1', resolve));
    +  const browser = await chromium.launch({ executablePath: process.env.PLAYWRIGHT_CHROMIUM_PATH, headless: true, args: ['--no-sandbox'] });
    +  try {
    +    for (const theme of ['light', 'dark']) {
    +      const context = await browser.newContext({ viewport: { width: 320, height: 720 } });
    +      await context.addInitScript(({ session, version, theme }) => {
    +        localStorage.setItem('sb-127-auth-token', JSON.stringify(session));
    +        localStorage.setItem('one-concept/last-seen-version/v1', version);
    +        localStorage.setItem('one-concept/theme/v1', theme);
    +      }, { session, version, theme });
    +      await context.route('**/api/**', async route => {
    +        const endpoint = new URL(route.request().url()).pathname.replace('/api', '');
    +        const body = endpoint === '/v1/me/state' ? state : endpoint === '/v1/topics'
    +          ? [{ slug: 'computer-science', name: 'Computer Science', concept_count: 25, following: true }]
    +          : endpoint === '/v1/me/notifications' ? { enabled: false, weekly_quiz_enabled: false, reminder_times: ['08:00'] }
    +          : endpoint === '/v1/me/achievements' || endpoint === '/v1/me/subtopics/progress' ? { items: [] }
    +          : concept;
    +        await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
    +      });
    +      await context.route('**/auth/v1/**', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(session) }));
    +      const page = await context.newPage();
    +      const errors = [];
    +      page.on('pageerror', error => errors.push(error.message));
    +      await page.goto('http://127.0.0.1:4781');
    +      await expect(page.getByText('A heading', { exact: true })).toBeVisible();
    +      await expect(page.getByText('bold', { exact: true })).toBeVisible();
    +      await expect(page.getByText('const', { exact: true })).toBeVisible();
    +      await expect(page.getByText('First item', { exact: true })).toBeVisible();
    +      const scrollableCode = await page.getByText('const', { exact: true }).evaluate(node => {
    +        for (let parent = node.parentElement; parent; parent = parent.parentElement) {
    +          if (getComputedStyle(parent).overflowX === 'auto' && parent.scrollWidth > parent.clientWidth) return true;
    +        }
    +        return false;
    +      });
    +      assert.equal(scrollableCode, true, 'long code stays in a horizontal scroll container');
    +      assert.equal(await page.locator('a[href^="javascript:"]').count(), 0);
    +      assert.equal((await page.locator('body').innerText()).includes('[unsafe](javascript:'), false);
    +      assert.deepEqual(errors, []);
    +      await page.screenshot({ path: `/tmp/markdown-lesson-${theme}.png`, fullPage: true, animations: 'disabled' });
    +      console.log(`${theme}: lesson Markdown renders safely`);
    +      await context.close();
    +    }
    +  } finally { await browser.close(); server.close(); }
    +})().catch(error => { console.error(error); server.close(); process.exitCode = 1; });
    diff --git a/mobile/tests/personalization.browser.cjs b/mobile/tests/personalization.browser.cjs
    index fa4bb155..9c2ef3d6 100644
    --- a/mobile/tests/personalization.browser.cjs
    +++ b/mobile/tests/personalization.browser.cjs
    @@ -7,4 +7,4 @@ const today=new Date().toISOString().slice(0,10);
     const firstSession={access_token:'first',refresh_token:'first-refresh',token_type:'bearer',expires_in:864000,expires_at:Math.floor(Date.now()/1000)+864000,user:{id:'11111111-1111-1111-1111-111111111111',email:'first@example.invalid',aud:'authenticated',role:'authenticated',app_metadata:{},user_metadata:{},created_at:'2026-01-01T00:00:00Z'}};
     const firstState={display_name:'First learner',timezone:'UTC',today,followed_topics:[],learned:[],likes:[],bookmarks:[],saved:[],stats:{current:0,longest:0,total_learned:0,total_reviews:0},assignment_slug:null,daily:null,daily_availability:'personalization_required'};
     const server=http.createServer((req,res)=>{const relative=decodeURIComponent(new URL(req.url,'http://localhost').pathname),file=path.join(root,relative==='/'?'index.html':relative);try{res.setHeader('Content-Type',({'.html':'text/html','.js':'application/javascript','.ttf':'font/ttf','.png':'image/png'})[path.extname(file)]||'application/octet-stream');res.end(fs.readFileSync(file));}catch{res.statusCode=404;res.end();}});
    -(async()=>{await new Promise(r=>server.listen(4781,'127.0.0.1',r));const browser=await chromium.launch({executablePath:process.env.PLAYWRIGHT_CHROMIUM_PATH,headless:true,args:['--no-sandbox']});try{for(const theme of ['light','dark']){let online=true;const errors=[];const context=await browser.newContext({viewport:{width:320,height:720}});await context.addInitScript(({session,version,theme})=>{localStorage.setItem('sb-127-auth-token',JSON.stringify(session));localStorage.setItem('one-concept/last-seen-version/v1',version);localStorage.setItem('one-concept/theme/v1',theme);},{session:firstSession,version,theme});await context.route('**/api/**',async route=>{if(!online)return route.abort('internetdisconnected');const endpoint=new URL(route.request().url()).pathname.replace('/api','');let body={};if(endpoint==='/v1/me/state')body=firstState;else if(endpoint==='/v1/topics')body=[{slug:'computer-science',name:'Computer Science',concept_count:25,following:false}];else if(endpoint==='/v1/me/notifications')body={enabled:false,weekly_quiz_enabled:false,reminder_times:['08:00']};await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify(body)});});await context.route('**/auth/v1/**',route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify(firstSession)}));const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:4781');await expect(page.getByText('Choose what you want to learn',{exact:true})).toBeVisible();await expect(page.getByText(/Follow one or more topics/)).toBeVisible();await expect(page.getByRole('button',{name:'Mark as learned'})).toHaveCount(0);await page.getByRole('tab',{name:'Profile'}).click();await expect(page.getByText('Edit profile',{exact:true})).toBeVisible();await expect(page.getByText('Weekly quiz alerts',{exact:true})).toBeVisible();await expect(page.getByText(/Enable daily reminders first/)).toBeVisible();await expect(page.getByRole('switch',{name:'Weekly quiz notifications'})).toBeDisabled();await page.getByRole('tab',{name:'Today'}).click();await expect(page.getByText('Choose what you want to learn',{exact:true})).toBeVisible();await page.getByRole('button',{name:'Choose topics'}).click();await expect(page.getByText('Personalization',{exact:true})).toBeVisible();await expect(page.getByRole('button',{name:'Follow'})).toBeVisible();await page.getByRole('button',{name:'Close personalization'}).click();await expect(page.getByText('Edit profile',{exact:true})).toBeVisible();await page.getByRole('tab',{name:'Today'}).click();online=false;await page.getByRole('button',{name:'Refresh today',exact:true}).click();await expect(page.getByText('Offline — showing your last saved plan',{exact:true})).toBeVisible();online=true;await page.evaluate(()=>document.querySelectorAll('div,span').forEach(el=>{if(el.childNodes.length===1&&el.firstChild?.nodeType===Node.TEXT_NODE){const style=getComputedStyle(el);el.style.fontSize=(parseFloat(style.fontSize)*1.8)+'px';}}));await expect(page.getByText('Choose what you want to learn',{exact:true})).toBeVisible();assert.deepEqual(errors,[]);console.log(`${theme}: personalization, offline, prerequisites, and large text passed`);await context.close();}}finally{await browser.close();server.close();}})().catch(e=>{console.error(e);server.close();process.exitCode=1;});
    +(async()=>{await new Promise(r=>server.listen(4781,'127.0.0.1',r));const browser=await chromium.launch({executablePath:process.env.PLAYWRIGHT_CHROMIUM_PATH,headless:true,args:['--no-sandbox']});try{for(const theme of ['light','dark']){let online=true;const errors=[];const context=await browser.newContext({viewport:{width:320,height:720}});await context.addInitScript(({session,version,theme})=>{localStorage.setItem('sb-127-auth-token',JSON.stringify(session));localStorage.setItem('one-concept/last-seen-version/v1',version);localStorage.setItem('one-concept/theme/v1',theme);},{session:firstSession,version,theme});await context.route('**/api/**',async route=>{if(!online)return route.abort('internetdisconnected');const endpoint=new URL(route.request().url()).pathname.replace('/api','');let body={};if(endpoint==='/v1/me/state')body=firstState;else if(endpoint==='/v1/topics')body=[{slug:'computer-science',name:'Computer Science',concept_count:25,following:false}];else if(endpoint==='/v1/me/notifications')body={enabled:false,weekly_quiz_enabled:false,reminder_times:['08:00']};await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify(body)});});await context.route('**/auth/v1/**',route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify(firstSession)}));const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:4781');await expect(page.getByText('Follow your interests to start learning',{exact:true})).toBeVisible();await expect(page.getByText(/Choose one or more topics/)).toBeVisible();await expect(page.getByRole('button',{name:'Mark as learned'})).toHaveCount(0);await page.getByRole('tab',{name:'Profile'}).click();await expect(page.getByText('Edit profile',{exact:true})).toBeVisible();await expect(page.getByText('Weekly quiz alerts',{exact:true})).toBeVisible();await expect(page.getByText(/Turn on Daily reminders first/)).toBeVisible();await expect(page.getByRole('switch',{name:'Weekly quiz notifications'})).toBeDisabled();await page.getByRole('tab',{name:'Today'}).click();await expect(page.getByText('Follow your interests to start learning',{exact:true})).toBeVisible();await page.getByRole('button',{name:'Choose topics'}).click();await expect(page.getByText('Personalization',{exact:true})).toBeVisible();await expect(page.getByRole('button',{name:'Follow'})).toBeVisible();await page.getByRole('button',{name:'Close personalization'}).click();await expect(page.getByText('Edit profile',{exact:true})).toBeVisible();await page.getByRole('tab',{name:'Today'}).click();online=false;await page.getByRole('button',{name:'Refresh today',exact:true}).click();await expect(page.getByText('Offline — showing your last saved plan',{exact:true})).toBeVisible();online=true;await page.evaluate(()=>document.querySelectorAll('div,span').forEach(el=>{if(el.childNodes.length===1&&el.firstChild?.nodeType===Node.TEXT_NODE){const style=getComputedStyle(el);el.style.fontSize=(parseFloat(style.fontSize)*1.8)+'px';}}));await expect(page.getByText('Follow your interests to start learning',{exact:true})).toBeVisible();assert.deepEqual(errors,[]);console.log(`${theme}: personalization, offline, prerequisites, and large text passed`);await context.close();}}finally{await browser.close();server.close();}})().catch(e=>{console.error(e);server.close();process.exitCode=1;});
    diff --git a/mobile/tests/personalizationFlow.browser.cjs b/mobile/tests/personalizationFlow.browser.cjs
    new file mode 100644
    index 00000000..29f49835
    --- /dev/null
    +++ b/mobile/tests/personalizationFlow.browser.cjs
    @@ -0,0 +1,94 @@
    +const { chromium, expect } = require(process.env.PLAYWRIGHT_TEST_MODULE || 'playwright/test');
    +const assert = require('node:assert/strict');
    +const fs = require('node:fs');
    +const http = require('node:http');
    +const path = require('node:path');
    +
    +const root = process.argv[2];
    +if (!root || !fs.existsSync(path.join(root, 'index.html'))) throw Error('Pass the exported web directory');
    +const version = require('../app.config.js').expo.version;
    +const today = new Date().toISOString().slice(0, 10);
    +const concept = {
    +  id: '33333333-3333-4333-8333-333333333333', slug: 'first-lesson', title: 'One selected lesson',
    +  summary: 'A lesson from the chosen topic.', example: 'Choose a topic first.',
    +  topic_slug: 'computer-science', topic_name: 'Computer Science', content_version: 1, like_count: 0,
    +};
    +const session = {
    +  access_token: 'fixture', refresh_token: 'fixture-refresh', token_type: 'bearer', expires_in: 864000,
    +  expires_at: Math.floor(Date.now() / 1000) + 864000,
    +  user: { id: '11111111-1111-1111-1111-111111111111', email: 'fixture@example.invalid',
    +    aud: 'authenticated', role: 'authenticated', app_metadata: {}, user_metadata: {}, created_at: '2026-01-01T00:00:00Z' },
    +};
    +const server = http.createServer((req, res) => {
    +  const relative = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
    +  const file = path.join(root, relative === '/' ? 'index.html' : relative);
    +  try {
    +    res.setHeader('Content-Type', ({ '.html': 'text/html', '.js': 'application/javascript', '.ttf': 'font/ttf', '.png': 'image/png' })[path.extname(file)] || 'application/octet-stream');
    +    res.end(fs.readFileSync(file));
    +  } catch { res.statusCode = 404; res.end(); }
    +});
    +
    +(async () => {
    +  await new Promise(resolve => server.listen(4781, '127.0.0.1', resolve));
    +  const browser = await chromium.launch({ executablePath: process.env.PLAYWRIGHT_CHROMIUM_PATH, headless: true, args: ['--no-sandbox'] });
    +  try {
    +    let followed = [];
    +    const writes = [];
    +    const errors = [];
    +    const context = await browser.newContext({ viewport: { width: 320, height: 720 } });
    +    await context.addInitScript(({ session, version }) => {
    +      localStorage.setItem('sb-127-auth-token', JSON.stringify(session));
    +      localStorage.setItem('one-concept/last-seen-version/v1', version);
    +    }, { session, version });
    +    await context.route('**/api/**', async route => {
    +      const endpoint = new URL(route.request().url()).pathname.replace('/api', '');
    +      if (endpoint === '/v1/me/topics' && route.request().method() === 'PUT') {
    +        followed = route.request().postDataJSON().topics;
    +        writes.push([...followed]);
    +      }
    +      const daily = followed.length ? {
    +        assigned_for: today, assigned_at: today + 'T08:00:00Z', learned: false, completed_at: null,
    +        outside_followed_topics: false, concept,
    +      } : null;
    +      const state = {
    +        display_name: 'First learner', timezone: 'UTC', today, followed_topics: followed,
    +        learned: [], likes: [], bookmarks: [], saved: [],
    +        stats: { current: 0, longest: 0, total_learned: 0, total_reviews: 0 },
    +        assignment_slug: daily ? concept.slug : null, daily,
    +        daily_availability: daily ? 'available' : 'personalization_required',
    +      };
    +      let body = {};
    +      if (endpoint === '/v1/me/state' || endpoint === '/v1/me/topics') body = state;
    +      else if (endpoint === '/v1/topics') body = [{ slug: 'computer-science', name: 'Computer Science', concept_count: 25, following: followed.includes('computer-science') }];
    +      else if (endpoint === '/v1/me/notifications') body = { enabled: false, weekly_quiz_enabled: false, reminder_times: ['08:00'] };
    +      await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
    +    });
    +    await context.route('**/auth/v1/**', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(session) }));
    +    const page = await context.newPage();
    +    page.on('pageerror', error => errors.push(error.message));
    +    await page.goto('http://127.0.0.1:4781');
    +    await expect(page.getByText('Follow your interests to start learning', { exact: true })).toBeVisible();
    +    await page.getByRole('button', { name: 'Choose topics' }).click();
    +    await page.getByRole('button', { name: 'Follow' }).click();
    +    await expect(page.getByRole('button', { name: 'Following' })).toBeVisible();
    +    await expect.poll(() => writes.length).toBe(1);
    +    await page.getByRole('button', { name: 'Close personalization' }).click();
    +    await page.getByRole('tab', { name: 'Today' }).click();
    +    await expect(page.getByText(concept.title, { exact: true })).toBeVisible({ timeout: 15000 });
    +    await page.reload();
    +    await expect(page.getByText(concept.title, { exact: true })).toBeVisible();
    +    await page.getByRole('tab', { name: 'Profile' }).click();
    +    await page.getByText('Personalize your feed', { exact: true }).click();
    +    await page.getByRole('button', { name: 'Following' }).click();
    +    await expect.poll(() => writes.length).toBe(2);
    +    assert.deepEqual(writes, [['computer-science'], []]);
    +    await page.getByRole('button', { name: 'Close personalization' }).click();
    +    await page.getByRole('tab', { name: 'Today' }).click();
    +    await expect(page.getByText('Follow your interests to start learning', { exact: true })).toBeVisible();
    +    await page.reload();
    +    await expect(page.getByText('Follow your interests to start learning', { exact: true })).toBeVisible();
    +    assert.deepEqual(errors, []);
    +    console.log('new follow, lesson, refresh, last unfollow, and empty-state refresh passed');
    +    await context.close();
    +  } finally { await browser.close(); server.close(); }
    +})().catch(error => { console.error(error); server.close(); process.exitCode = 1; });
    diff --git a/mobile/tests/profilePhoto.browser.cjs b/mobile/tests/profilePhoto.browser.cjs
    index d5daaf39..505f04f0 100644
    --- a/mobile/tests/profilePhoto.browser.cjs
    +++ b/mobile/tests/profilePhoto.browser.cjs
    @@ -26,9 +26,9 @@ const server = http.createServer((req, res) => {
       const browser = await chromium.launch({ executablePath: process.env.PLAYWRIGHT_CHROMIUM_PATH, headless: true, args: ['--no-sandbox'] });
       try {
         for (const theme of ['light', 'dark']) {
    -      let failUpload = true;
    +      let uploadStatus = 503;
           let uploads = 0;
    -      const state = { display_name: 'Reader', bio: null, avatar_ref: null, avatar_url: null, timezone: 'UTC', today, followed_topics: ['computer-science'], learned: [], likes: [], bookmarks: [], saved: [], stats: { current: 0, longest: 0, total_learned: 0, total_reviews: 0 }, assignment_slug: concept.slug, daily: { assigned_for: today, assigned_at: today + 'T08:00:00Z', learned: false, completed_at: null, outside_followed_topics: false, concept } };
    +      const state = { display_name: 'Reader', bio: null, avatar_ref: 'preset:ocean', avatar_url: null, timezone: 'UTC', today, followed_topics: ['computer-science'], learned: [], likes: [], bookmarks: [], saved: [], stats: { current: 0, longest: 0, total_learned: 0, total_reviews: 0 }, assignment_slug: concept.slug, daily: { assigned_for: today, assigned_at: today + 'T08:00:00Z', learned: false, completed_at: null, outside_followed_topics: false, concept } };
           const context = await browser.newContext({ viewport: { width: 320, height: 740 } });
           await context.addInitScript(({ session, version, theme }) => {
             localStorage.setItem('sb-127-auth-token', JSON.stringify(session));
    @@ -41,7 +41,12 @@ const server = http.createServer((req, res) => {
             if (endpoint === '/v1/me/state') body = state;
             else if (endpoint === '/v1/me/avatar' && route.request().method() === 'PUT') {
               uploads++;
    -          if (failUpload) { status = 503; body = { detail: 'Photo uploads unavailable' }; }
    +          const bodyBytes = route.request().postDataBuffer();
    +          assert.equal(route.request().headers()['content-type'], 'image/jpeg');
    +          assert.deepEqual([...bodyBytes.subarray(0, 3)], [0xff, 0xd8, 0xff]);
    +          assert.ok(bodyBytes.length > 0 && bodyBytes.length <= 5 * 1024 * 1024);
    +          if (uploadStatus === 0) return route.abort('internetdisconnected');
    +          if (uploadStatus) { status = uploadStatus; body = { detail: 'Upload rejected' }; }
               else { state.avatar_ref = 'avatars/fixture/photo.jpg'; state.avatar_url = 'data:image/png;base64,' + png.toString('base64'); body = state; }
             } else if (endpoint === '/v1/me/achievements') body = { items: [] };
             else if (endpoint === '/v1/me/subtopics/progress') body = { items: [] };
    @@ -76,13 +81,64 @@ const server = http.createServer((req, res) => {
           await expect(page.getByText(/Photo saving is unavailable/)).toBeVisible();
           await expect(page.getByText('Preview your photo')).toBeVisible();
           assert.equal(uploads, 1);
    -      failUpload = false;
    +      assert.equal(state.avatar_ref, 'preset:ocean', 'failed upload must keep the current avatar');
    +      uploadStatus = 413;
    +      await page.getByRole('button', { name: 'Save photo' }).click();
    +      await expect(page.getByText(/photo is too large/)).toBeVisible();
    +      uploadStatus = 415;
    +      await page.getByRole('button', { name: 'Save photo' }).click();
    +      await expect(page.getByText(/photo format could not be used/)).toBeVisible();
    +      uploadStatus = 0;
    +      await page.getByRole('button', { name: 'Save photo' }).click();
    +      await expect(page.getByText(/Could not reach the photo service/)).toBeVisible();
    +      assert.equal(state.avatar_ref, 'preset:ocean');
    +      await expect(page.getByRole('button', { name: 'Save photo' })).toBeEnabled();
    +      uploadStatus = null;
           await page.getByRole('button', { name: 'Save photo' }).click();
           await expect(page.getByText('Profile photo saved.')).toBeVisible();
           await expect(page.getByText('Preview your photo')).toHaveCount(0);
    -      assert.equal(uploads, 2);
    +      assert.equal(uploads, 5);
    +      const largePng = Buffer.from(await page.evaluate(() => {
    +        const canvas = document.createElement('canvas');
    +        canvas.width = 1600; canvas.height = 1600;
    +        const pixels = canvas.getContext('2d').createImageData(1600, 1600);
    +        let seed = 1234567;
    +        for (let i = 0; i < pixels.data.length; i += 4) {
    +          seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
    +          pixels.data[i] = seed & 255;
    +          pixels.data[i + 1] = (seed >> 8) & 255;
    +          pixels.data[i + 2] = (seed >> 16) & 255;
    +          pixels.data[i + 3] = 255;
    +        }
    +        canvas.getContext('2d').putImageData(pixels, 0, 0);
    +        return canvas.toDataURL('image/png').split(',')[1];
    +      }), 'base64');
    +      assert.ok(largePng.length > 5 * 1024 * 1024, 'source must exceed the API upload limit');
    +      const largeChooser = page.waitForEvent('filechooser');
    +      await page.getByRole('button', { name: 'Choose photo' }).click();
    +      await (await largeChooser).setFiles({ name: 'large.png', mimeType: 'image/png', buffer: largePng });
    +      await page.getByRole('button', { name: 'Save photo' }).click();
    +      await expect(page.getByText('Profile photo saved.')).toBeVisible();
    +      assert.equal(uploads, 6);
    +      const cameraChooser = page.waitForEvent('filechooser');
    +      await page.getByRole('button', { name: 'Take photo' }).click();
    +      await (await cameraChooser).setFiles({ name: 'camera.png', mimeType: 'image/png', buffer: png });
    +      await page.getByRole('button', { name: 'Save photo' }).click();
    +      await expect(page.getByText('Profile photo saved.')).toBeVisible();
    +      assert.equal(uploads, 7);
    +      await page.setViewportSize({ width: 320, height: 420 });
    +      const name = page.getByRole('textbox', { name: 'Preferred name' });
    +      const bio = page.getByRole('textbox', { name: 'Short bio' });
    +      await name.fill('Reader');
    +      await bio.fill('A short profile bio that remains editable in a compact viewport.');
    +      await page.waitForTimeout(150);
    +      const bioBounds = await bio.boundingBox();
    +      assert.ok(bioBounds && bioBounds.y >= 0 && bioBounds.y + bioBounds.height <= 420,
    +        'focused bio must be visible in a short viewport');
    +      await page.getByRole('button', { name: 'Save profile' }).scrollIntoViewIfNeeded();
    +      await expect(page.getByRole('button', { name: 'Save profile' })).toBeVisible();
           assert.deepEqual(errors, []);
    -      console.log(`${theme}: preview, cancellation, upload failure/retry, confirmed save passed`);
    +      console.log(`${theme}: gallery/camera, upload recovery, compact profile inputs passed`);
           await context.close();
         }
       } finally { await browser.close(); server.close(); }
    diff --git a/mobile/tests/topicStore.test.mjs b/mobile/tests/topicStore.test.mjs
    index 4ae543a0..e11ded73 100644
    --- a/mobile/tests/topicStore.test.mjs
    +++ b/mobile/tests/topicStore.test.mjs
    @@ -15,8 +15,8 @@ function setup() {
       return { deps, calls, store: new TopicStore(deps) };
     }
     function deferred() {
    -  let resolve;
    -  return { promise: new Promise(r => { resolve=r; }), resolve: (...args) => resolve(...args) };
    +  let resolve, reject;
    +  return { promise: new Promise((r, j) => { resolve=r; reject=j; }), resolve: (...args) => resolve(...args), reject: (...args) => reject(...args) };
     }
     const tick = () => new Promise(resolve => setImmediate(resolve));
     
    @@ -75,3 +75,77 @@ test('account reset suppresses late topic fetches and queued follow writes', asy
       assert.deepEqual(store.getSnapshot(), []);
       assert.deepEqual(calls, []);
     });
    +
    +test('last unfollow stays empty across a stale catalog reload and a new store', async () => {
    +  const { store, deps, calls } = setup();
    +  await store.load();
    +  await store.toggle('computer-science');
    +  assert.deepEqual(calls, [[]]);
    +  assert.equal(store.getSnapshot().some(topic => topic.following), false);
    +  await store.load();
    +  assert.equal(store.getSnapshot().some(topic => topic.following), false);
    +  const reopened = new TopicStore(deps);
    +  await reopened.load();
    +  assert.equal(reopened.getSnapshot().some(topic => topic.following), false);
    +});
    +
    +test('a refresh cannot restore a stale follow while the last-unfollow enqueue is pending', async () => {
    +  const { store, deps } = setup();
    +  await store.load();
    +  const enqueued = deferred();
    +  const releaseEnqueue = deferred();
    +  deps.enqueue = async slugs => {
    +    enqueued.resolve();
    +    await releaseEnqueue.promise;
    +    deps.queued = slugs;
    +  };
    +  deps.pending = async () => deps.queued;
    +
    +  const unfollow = store.toggle('computer-science');
    +  await enqueued.promise;
    +  const refresh = store.load();
    +  await tick();
    +  releaseEnqueue.resolve();
    +  await Promise.all([unfollow, refresh]);
    +  assert.equal(store.getSnapshot()[0].following, false);
    +});
    +
    +test('a failed pending enqueue still rolls back when a refresh also fails offline', async () => {
    +  const { store, deps } = setup();
    +  await store.load();
    +  const enqueued = deferred();
    +  const releaseEnqueue = deferred();
    +  deps.enqueue = async () => {
    +    enqueued.resolve();
    +    await releaseEnqueue.promise;
    +  };
    +  deps.fetch = async () => { throw new Error('offline'); };
    +
    +  const unfollow = store.toggle('computer-science');
    +  await enqueued.promise;
    +  const refresh = store.load();
    +  releaseEnqueue.reject(new Error('queue storage unavailable'));
    +  await assert.rejects(unfollow, /queue storage unavailable/);
    +  await refresh;
    +  assert.equal(store.getSnapshot()[0].following, true);
    +});
    +
    +test('catalog cache failure cannot roll back a durable follow intent', async () => {
    +  const { store, deps, calls } = setup();
    +  await store.load();
    +  deps.write = async () => { throw new Error('catalog cache unavailable'); };
    +  await store.toggle('new-server-topic');
    +  assert.deepEqual(calls, [['computer-science', 'new-server-topic']]);
    +  assert.equal(store.getSnapshot()[1].following, true);
    +  const reopened = new TopicStore(deps);
    +  await reopened.load();
    +  assert.equal(reopened.getSnapshot()[1].following, true);
    +});
    +
    +test('failed durable enqueue rolls back the visible topic choice', async () => {
    +  const { store, deps } = setup();
    +  await store.load();
    +  deps.enqueue = async () => { throw new Error('queue storage unavailable'); };
    +  await assert.rejects(store.toggle('new-server-topic'), /queue storage unavailable/);
    +  assert.equal(store.getSnapshot()[1].following, false);
    +});