Repository: phoenixframework/phoenix
Pull request #5330 (2023-03-06 13:39:34)

Rename id to ensure all ids are unique.
**Replaces https://github.com/phoenixframework/phoenix/pull/5323**

The template for user settings renders two DOM nodes with the same id. One is the visible email input on the change email form and the other is a hidden input on the change password form.

This PR gives the hidden email input a specific id of hidden_user_email to ensure valid HTML and prevent the error "Multiple IDs detected: user_email. Ensure unique element ids." from being displayed in the developer console.

_I am not sure how I messed up the first branch but this is now a clean branch that should be green. On the bright side my fork now has the actions running so I should avoid red PRs in the future_.


Patch:
diff --git a/guides/introduction/packages_glossary.md b/guides/introduction/packages_glossary.md
index 7f6f06b68..21e2266be 100644
--- a/guides/introduction/packages_glossary.md
+++ b/guides/introduction/packages_glossary.md
@@ -33,7 +33,7 @@ You will also work with the following:
   * [Swoosh](https://hexdocs.pm/swoosh) - a library for composing,
     delivering and testing emails, also used by `mix phx.gen.auth`
 
-When peaking under the covers, you will find those libraries play
+When peeking under the covers, you will find those libraries play
 an important role in Phoenix applications:
 
   * [Phoenix HTML](https://hexdocs.pm/phoenix_html) - building blocks
diff --git a/guides/plug.md b/guides/plug.md
index 107bbd455..16774d093 100644
--- a/guides/plug.md
+++ b/guides/plug.md
@@ -116,11 +116,10 @@ defmodule HelloWeb.Router do
 
 In the [`init/1`] callback, we pass a default locale to use if none is present in the params. We also use pattern matching to define multiple [`call/2`] function heads to validate the locale in the params, and fall back to `"en"` if there is no match. The [`assign/3`] is a part of the `Plug.Conn` module and it's how we store values in the `conn` data structure.
 
-To see the assign in action, go to the layout in `lib/hello_web/components/layouts/app.html.heex` and add the following code to the main container:
+To see the assign in action, go to the template in `lib/hello_web/components/layouts/home.html.heex` and add the following code after the closing of the `</h1>` tag:
 
 ```heex
-<main class="px-4 py-20 sm:px-6 lg:px-8">
-  <p>Locale: <%= @locale %></p>
+<p>Locale: <%= @locale %></p>
 ```
 
 Go to [http://localhost:4000/](http://localhost:4000/) and you should see the locale exhibited. Visit [http://localhost:4000/?locale=fr](http://localhost:4000/?locale=fr) and you should see the assign changed to `"fr"`. Someone can use this information alongside [Gettext](https://hexdocs.pm/gettext/Gettext.html) to provide a fully internationalized web application.
diff --git a/guides/request_lifecycle.md b/guides/request_lifecycle.md
index 9bc55774c..90d5d1a80 100644
--- a/guides/request_lifecycle.md
+++ b/guides/request_lifecycle.md
@@ -46,7 +46,7 @@ defmodule HelloWeb.Router do
     plug :accepts, ["html"]
     plug :fetch_session
     plug :fetch_live_flash
-    plug :put_root_layout, {HelloWeb.LayoutView, :root}
+    plug :put_root_layout, {HelloWeb.Layouts, :root}
     plug :protect_from_forgery
     plug :put_secure_browser_headers
   end
@@ -103,7 +103,7 @@ We'll save a discussion of `use HelloWeb, :controller` for the [Controllers guid
 
 All controller actions take two arguments. The first is `conn`, a struct which holds a ton of data about the request. The second is `params`, which are the request parameters. Here, we are not using `params`, and we avoid compiler warnings by prefixing it with `_`.
 
-The core of this action is `render(conn, :index)`. It tells Phoenix to render the `index` template. The modules responsible for rendering are called views. By default, Phoenix views are named after the controller and format they support, so Phoenix is expecting a `HelloWeb.HelloHTML` to exist and define an `index/1` function for us.
+The core of this action is `render(conn, :index)`. It tells Phoenix to render the `index` template. The modules responsible for rendering are called views. By default, Phoenix views are named after the controller (`HelloController`) and format (`HTML` in this case), so Phoenix is expecting a `HelloWeb.HelloHTML` to exist and define an `index/1` function.
 
 ### A new view
 
@@ -153,6 +153,8 @@ Next, we need to add files to the `lib/hello_web/controllers/hello_html` directo
 
 Note the controller name (`HelloController`), the view name (`HelloHTML`), and the template directory (`hello_html`) all follow the same naming convention and are named after each other. They are also collocated together in the directory tree:
 
+> **Note**: We can rename the `hello_html` directory to whatever we want and put it in a subdirectory of `lib/hello_web/controllers`, as long as we update the `embed_templates` setting accordingly. However, it's best to keep the same naming convention to prevent any confusion.
+
 ```
 lib/hello_web
 ├── controllers
diff --git a/installer/lib/mix/tasks/phx.new.ex b/installer/lib/mix/tasks/phx.new.ex
index 82822f580..db311d582 100644
--- a/installer/lib/mix/tasks/phx.new.ex
+++ b/installer/lib/mix/tasks/phx.new.ex
@@ -31,23 +31,29 @@ defmodule Mix.Tasks.Phx.New do
       Please check the driver docs for more information
       and requirements. Defaults to "postgres".
 
-    * `--no-assets` - do not generate the assets folder.
-      When choosing this option, you will need to manually
-      handle JavaScript/CSS if building HTML apps
+    * `--no-assets` - equivalent to `--no-esbuild` and `--no-tailwind`
+
+    * `--no-dashboard` - do not include Phoenix.LiveDashboard
 
     * `--no-ecto` - do not generate Ecto files
 
-    * `--no-html` - do not generate HTML views
+    * `--no-esbuild` - do not include esbuild dependencies and assets.
+      We do not recommend setting this option, unless for API only
+      applications, as doing so requires you to manually add and
+      track JavaScript dependencies
 
     * `--no-gettext` - do not generate gettext files
 
-    * `--no-dashboard` - do not include Phoenix.LiveDashboard
+    * `--no-html` - do not generate HTML views
 
     * `--no-live` - comment out LiveView socket setup in assets/js/app.js.
       Automatically disabled if --no-html is given
 
     * `--no-mailer` - do not generate Swoosh mailer files
 
+    * `--no-tailwind` - do not include tailwind dependencies and assets.
+      The home page is still styled with Tailwind CSS, but it can safely deleted
+
     * `--binary-id` - use `binary_id` as primary key type in Ecto schemas
 
     * `--verbose` - use verbose output
@@ -105,12 +111,27 @@ defmodule Mix.Tasks.Phx.New do
   @version Mix.Project.config()[:version]
   @shortdoc "Creates a new Phoenix v#{@version} application"
 
-  @switches [dev: :boolean, assets: :boolean, ecto: :boolean,
-             app: :string, module: :string, web_module: :string,
-             database: :string, binary_id: :boolean, html: :boolean,
-             gettext: :boolean, umbrella: :boolean, verbose: :boolean,
-             live: :boolean, dashboard: :boolean, install: :boolean,
-             prefix: :string, mailer: :boolean]
+  @switches [
+    dev: :boolean,
+    assets: :boolean,
+    esbuild: :boolean,
+    tailwind: :boolean,
+    ecto: :boolean,
+    app: :string,
+    module: :string,
+    web_module: :string,
+    database: :string,
+    binary_id: :boolean,
+    html: :boolean,
+    gettext: :boolean,
+    umbrella: :boolean,
+    verbose: :boolean,
+    live: :boolean,
+    dashboard: :boolean,
+    install: :boolean,
+    prefix: :string,
+    mailer: :boolean
+  ]
 
   @impl true
   def run([version]) when version in ~w(-v --version) do
@@ -119,7 +140,8 @@ defmodule Mix.Tasks.Phx.New do
 
   def run(argv) do
     elixir_version_check!()
-    case parse_opts(argv) do
+
+    case OptionParser.parse!(argv, strict: @switches) do
       {_opts, []} ->
         Mix.Tasks.Help.run(["phx.new"])
 
@@ -132,7 +154,8 @@ defmodule Mix.Tasks.Phx.New do
   @doc false
   def run(argv, generator, path) do
     elixir_version_check!()
-    case parse_opts(argv) do
+
+    case OptionParser.parse!(argv, strict: @switches) do
       {_opts, []} -> Mix.Tasks.Help.run(["phx.new"])
       {opts, [base_path | _]} -> generate(base_path, generator, path, opts)
     end
@@ -171,20 +194,20 @@ defmodule Mix.Tasks.Phx.New do
       mix_step = install_mix(project, install?)
 
       if mix_step == [] do
-        tasks =
-          if Keyword.get(project.opts, :assets, true) do
-            Mix.shell().info([:green, "* running ", :reset, "mix assets.setup"])
+        builders = Keyword.fetch!(project.binding, :asset_builders)
 
-            # First compile only esbuild and tailwind so we can install in parallel
-            cmd(project, "mix deps.compile castore esbuild tailwind", false)
+        if builders != [] do
+          Mix.shell().info([:green, "* running ", :reset, "mix assets.setup"])
+
+          # First compile only builders so we can install in parallel
+          cmd(project, "mix deps.compile castore #{Enum.join(builders, " ")}", false)
+        end
 
-            Enum.map(
-              ["mix do loadpaths --no-compile + tailwind.install", "mix do loadpaths --no-compile + esbuild.install"],
-              &Task.async(fn -> cmd(project, &1, false) end)
-            )
-          else
-            []
-          end
+        tasks =
+          Enum.map(builders, fn builder ->
+            cmd = "mix do loadpaths --no-compile + #{builder}.install"
+            Task.async(fn -> cmd(project, cmd, false) end)
+          end)
 
         if rebar_available?() do
           cmd(project, "mix deps.compile")
@@ -213,18 +236,8 @@ defmodule Mix.Tasks.Phx.New do
       print_mix_info(generator)
     end)
   end
-  defp maybe_cd(path, func), do: path && File.cd!(path, func)
 
-  defp parse_opts(argv) do
-    case OptionParser.parse(argv, strict: @switches) do
-      {opts, argv, []} ->
-        {opts, argv}
-      {_opts, _argv, [switch | _]} ->
-        Mix.raise "Invalid option: " <> switch_to_string(switch)
-    end
-  end
-  defp switch_to_string({name, nil}), do: name
-  defp switch_to_string({name, val}), do: name <> "=" <> val
+  defp maybe_cd(path, func), do: path && File.cd!(path, func)
 
   defp install_mix(project, install?) do
     if install? && hex_available?() do
@@ -244,32 +257,34 @@ defmodule Mix.Tasks.Phx.New do
   end
 
   defp print_missing_steps(steps) do
-    Mix.shell().info """
+    Mix.shell().info("""
 
     We are almost there! The following steps are missing:
 
         #{Enum.join(steps, "\n    ")}
-    """
+    """)
   end
 
   defp print_ecto_info(Web), do: :ok
+
   defp print_ecto_info(_gen) do
-    Mix.shell().info """
+    Mix.shell().info("""
     Then configure your database in config/dev.exs and run:
 
         $ mix ecto.create
-    """
+    """)
   end
 
   defp print_mix_info(Ecto) do
-    Mix.shell().info """
+    Mix.shell().info("""
     You can run your app inside IEx (Interactive Elixir) as:
 
         $ iex -S mix
-    """
+    """)
   end
+
   defp print_mix_info(_gen) do
-    Mix.shell().info """
+    Mix.shell().info("""
     Start your Phoenix app with:
 
         $ mix phx.server
@@ -277,7 +292,7 @@ defmodule Mix.Tasks.Phx.New do
     You can also run your app inside IEx (Interactive Elixir) as:
 
         $ iex -S mix phx.server
-    """
+    """)
   end
 
   defp relative_app_path(path) do
@@ -291,7 +306,7 @@ defmodule Mix.Tasks.Phx.New do
 
   defp cmd(%Project{} = project, cmd, log? \\ true) do
     if log? do
-      Mix.shell().info [:green, "* running ", :reset, cmd]
+      Mix.shell().info([:green, "* running ", :reset, cmd])
     end
 
     case Mix.shell().cmd(cmd, cmd_opts(project)) do
@@ -313,19 +328,23 @@ defmodule Mix.Tasks.Phx.New do
       extra =
         if !from_app_flag do
           ". The application name is inferred from the path, if you'd like to " <>
-          "explicitly name the application then use the `--app APP` option."
+            "explicitly name the application then use the `--app APP` option."
         else
           ""
         end
 
-      Mix.raise "Application name must start with a letter and have only lowercase " <>
-                "letters, numbers and underscore, got: #{inspect name}" <> extra
+      Mix.raise(
+        "Application name must start with a letter and have only lowercase " <>
+          "letters, numbers and underscore, got: #{inspect(name)}" <> extra
+      )
     end
   end
 
   defp check_module_name_validity!(name) do
     unless inspect(name) =~ Regex.recompile!(~r/^[A-Z]\w*(\.[A-Z]\w*)*$/) do
-      Mix.raise "Module name must be a valid Elixir alias (for example: Foo.Bar), got: #{inspect name}"
+      Mix.raise(
+        "Module name must be a valid Elixir alias (for example: Foo.Bar), got: #{inspect(name)}"
+      )
     end
   end
 
@@ -334,25 +353,31 @@ defmodule Mix.Tasks.Phx.New do
     |> Module.concat()
     |> Module.split()
     |> Enum.reduce([], fn name, acc ->
-        mod = Module.concat([Elixir, name | acc])
-        if Code.ensure_loaded?(mod) do
-          Mix.raise "Module name #{inspect mod} is already taken, please choose another name"
-        else
-          [name | acc]
-        end
+      mod = Module.concat([Elixir, name | acc])
+
+      if Code.ensure_loaded?(mod) do
+        Mix.raise("Module name #{inspect(mod)} is already taken, please choose another name")
+      else
+        [name | acc]
+      end
     end)
   end
 
   defp check_directory_existence!(path) do
-    if File.dir?(path) and not Mix.shell().yes?("The directory #{path} already exists. Are you sure you want to continue?") do
-      Mix.raise "Please select another directory for installation."
+    if File.dir?(path) and
+         not Mix.shell().yes?(
+           "The directory #{path} already exists. Are you sure you want to continue?"
+         ) do
+      Mix.raise("Please select another directory for installation.")
     end
   end
 
   defp elixir_version_check! do
     unless Version.match?(System.version(), "~> 1.14") do
-      Mix.raise "Phoenix v#{@version} requires at least Elixir v1.14\n " <>
-                "You have #{System.version()}. Please update accordingly"
+      Mix.raise(
+        "Phoenix v#{@version} requires at least Elixir v1.14\n " <>
+          "You have #{System.version()}. Please update accordingly"
+      )
     end
   end
 end
diff --git a/installer/lib/phx_new/generator.ex b/installer/lib/phx_new/generator.ex
index f43383dd9..00b11e5cb 100644
--- a/installer/lib/phx_new/generator.ex
+++ b/installer/lib/phx_new/generator.ex
@@ -189,6 +189,8 @@ defmodule Phx.New.Generator do
     dashboard = Keyword.get(opts, :dashboard, true)
     gettext = Keyword.get(opts, :gettext, true)
     assets = Keyword.get(opts, :assets, true)
+    esbuild = Keyword.get(opts, :esbuild, assets)
+    tailwind = Keyword.get(opts, :tailwind, assets)
     mailer = Keyword.get(opts, :mailer, true)
     dev = Keyword.get(opts, :dev, false)
     phoenix_path = phoenix_path(project, dev, false)
@@ -230,7 +232,9 @@ defmodule Phx.New.Generator do
       signing_salt: random_string(8),
       lv_signing_salt: random_string(8),
       in_umbrella: project.in_umbrella?,
-      assets: assets,
+      asset_builders: Enum.filter([tailwind && :tailwind, esbuild && :esbuild], & &1),
+      javascript: esbuild,
+      css: tailwind,
       mailer: mailer,
       ecto: ecto,
       html: html,
diff --git a/installer/lib/phx_new/project.ex b/installer/lib/phx_new/project.ex
index 78d2f54bd..c8a20df76 100644
--- a/installer/lib/phx_new/project.ex
+++ b/installer/lib/phx_new/project.ex
@@ -53,8 +53,12 @@ defmodule Phx.New.Project do
     Keyword.fetch!(binding, :dashboard)
   end
 
-  def assets?(%Project{binding: binding}) do
-    Keyword.fetch!(binding, :assets)
+  def javascript?(%Project{binding: binding}) do
+    Keyword.fetch!(binding, :javascript)
+  end
+
+  def css?(%Project{binding: binding}) do
+    Keyword.fetch!(binding, :css)
   end
 
   def mailer?(%Project{binding: binding}) do
diff --git a/installer/lib/phx_new/single.ex b/installer/lib/phx_new/single.ex
index 7738a0a24..cc97514a1 100644
--- a/installer/lib/phx_new/single.ex
+++ b/installer/lib/phx_new/single.ex
@@ -54,7 +54,7 @@ defmodule Phx.New.Single do
      "phx_web/components/layouts/app.html.heex":
        "lib/:lib_web_name/components/layouts/app.html.heex",
      "phx_web/components/layouts.ex": "lib/:lib_web_name/components/layouts.ex"},
-    {:eex, :web, "phx_assets/topbar.js": "assets/vendor/topbar.js"}
+    {:eex, :web, "phx_assets/logo.svg": "priv/static/images/logo.svg"}
   ])
 
   template(:ecto, [
@@ -66,21 +66,29 @@ defmodule Phx.New.Single do
     {:keep, :app, "phx_ecto/priv/repo/migrations": "priv/repo/migrations"}
   ])
 
-  template(:assets, [
+  template(:css, [
     {:eex, :web,
      "phx_assets/app.css": "assets/css/app.css",
-     "phx_assets/app.js": "assets/js/app.js",
      "phx_assets/tailwind.config.js": "assets/tailwind.config.js"},
-    {:keep, :web, "phx_assets/vendor": "assets/vendor"},
-    {:eex, :web, "phx_assets/hero_icons/LICENSE.md": "priv/hero_icons/LICENSE.md"},
-    {:eex, :web, "phx_assets/hero_icons/UPGRADE.md": "priv/hero_icons/UPGRADE.md"},
-    {:zip, :web, "phx_assets/hero_icons/optimized.zip": "priv/hero_icons/optimized"}
+    {:eex, :web,
+     "phx_assets/heroicons/LICENSE.md": "assets/vendor/heroicons/LICENSE.md",
+     "phx_assets/heroicons/UPGRADE.md": "assets/vendor/heroicons/UPGRADE.md"},
+    {:zip, :web, "phx_assets/heroicons/optimized.zip": "assets/vendor/heroicons/optimized"}
+  ])
+
+  template(:js, [
+    {:eex, :web,
+     "phx_assets/app.js": "assets/js/app.js", "phx_assets/topbar.js": "assets/vendor/topbar.js"}
+  ])
+
+  template(:no_js, [
+    {:text, :web, "phx_static/app.js": "priv/static/assets/app.js"}
   ])
 
-  template(:no_assets, [
+  template(:no_css, [
     {:text, :web,
      "phx_static/app.css": "priv/static/assets/app.css",
-     "phx_static/app.js": "priv/static/assets/app.js"}
+     "phx_static/home.css": "priv/static/assets/home.css"}
   ])
 
   template(:static, [
@@ -148,10 +156,20 @@ defmodule Phx.New.Single do
   end
 
   def gen_assets(%Project{} = project) do
-    if Project.assets?(project) or Project.html?(project) do
-      command = if Project.assets?(project), do: :assets, else: :no_assets
+    javascript? = Project.javascript?(project)
+    css? = Project.css?(project)
+    html? = Project.html?(project)
+
+    copy_from(project, __MODULE__, :static)
+
+    if html? or javascript? do
+      command = if javascript?, do: :js, else: :no_js
+      copy_from(project, __MODULE__, command)
+    end
+
+    if html? or css? do
+      command = if css?, do: :css, else: :no_css
       copy_from(project, __MODULE__, command)
-      copy_from(project, __MODULE__, :static)
     end
   end
 
diff --git a/installer/templates/phx_assets/hero_icons/LICENSE.md b/installer/templates/phx_assets/heroicons/LICENSE.md
similarity index 100%
rename from installer/templates/phx_assets/hero_icons/LICENSE.md
rename to installer/templates/phx_assets/heroicons/LICENSE.md
diff --git a/installer/templates/phx_assets/hero_icons/UPGRADE.md b/installer/templates/phx_assets/heroicons/UPGRADE.md
similarity index 100%
rename from installer/templates/phx_assets/hero_icons/UPGRADE.md
rename to installer/templates/phx_assets/heroicons/UPGRADE.md
diff --git a/installer/templates/phx_assets/hero_icons/optimized.zip b/installer/templates/phx_assets/heroicons/optimized.zip
similarity index 100%
rename from installer/templates/phx_assets/hero_icons/optimized.zip
rename to installer/templates/phx_assets/heroicons/optimized.zip
diff --git a/installer/templates/phx_assets/logo.svg b/installer/templates/phx_assets/logo.svg
new file mode 100644
index 000000000..7347634f1
--- /dev/null
+++ b/installer/templates/phx_assets/logo.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 71 48" fill="currentColor" aria-hidden="true">
+  <path
+    d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.043.03a2.96 2.96 0 0 0 .04-.029c-.038-.117-.107-.12-.197-.054l.122.107c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728.374.388.763.768 1.182 1.106 1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zm17.29-19.32c0-.023.001-.045.003-.068l-.006.006.006-.006-.036-.004.021.018.012.053Zm-20 14.744a7.61 7.61 0 0 0-.072-.041.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zm-.072-.041-.008-.034-.008.01.008-.01-.022-.006.005.026.024.014Z"
+    fill="#FD4F00"
+  />
+</svg>
\ No newline at end of file
diff --git a/installer/templates/phx_assets/tailwind.config.js b/installer/templates/phx_assets/tailwind.config.js
index cc9e6a276..11740338e 100644
--- a/installer/templates/phx_assets/tailwind.config.js
+++ b/installer/templates/phx_assets/tailwind.config.js
@@ -34,7 +34,7 @@ module.exports = {
     // See your `CoreComponents.icon/1` for more information.
     //
     plugin(function({matchComponents, theme}) {
-      let iconsDir = path.join(__dirname, "../priv/hero_icons/optimized")
+      let iconsDir = path.join(__dirname, "./vendor/heroicons/optimized")
       let values = {}
       let icons = [
         ["", "/24/outline"],
diff --git a/installer/templates/phx_single/config/config.exs b/installer/templates/phx_single/config/config.exs
index 7db0e7fb1..a3241c5ce 100644
--- a/installer/templates/phx_single/config/config.exs
+++ b/installer/templates/phx_single/config/config.exs
@@ -29,7 +29,7 @@ config :<%= @app_name %>, <%= @endpoint_module %>,
 #
 # For production it's recommended to configure a different adapter
 # at the `config/runtime.exs`.
-config :<%= @app_name %>, <%= @app_module %>.Mailer, adapter: Swoosh.Adapters.Local<% end %><%= if @assets do %>
+config :<%= @app_name %>, <%= @app_module %>.Mailer, adapter: Swoosh.Adapters.Local<% end %><%= if @javascript do %>
 
 # Configure esbuild (the version is required)
 config :esbuild,
@@ -39,7 +39,7 @@ config :esbuild,
       ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
     cd: Path.expand("../assets", __DIR__),
     env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
-  ]
+  ]<% end %><%= if @css do %>
 
 # Configure tailwind (the version is required)
 config :tailwind,
diff --git a/installer/templates/phx_single/config/dev.exs b/installer/templates/phx_single/config/dev.exs
index 2e610184d..ae2699692 100644
--- a/installer/templates/phx_single/config/dev.exs
+++ b/installer/templates/phx_single/config/dev.exs
@@ -14,9 +14,9 @@ config :<%= @app_name %>, <%= @endpoint_module %>,
   code_reloader: true,
   debug_errors: true,
   secret_key_base: "<%= @secret_key_base_dev %>",
-  watchers: <%= if @assets do %>[
-    esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]},
-    tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]}
+  watchers: <%= if @javascript or @css do %>[<%= if @javascript do %>
+    esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}<%= if @css, do: "," %><% end %><%= if @css do %>
+    tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]}<% end %>
   ]<% else %>[]<% end %>
 
 # ## SSL Support
diff --git a/installer/templates/phx_single/config/prod.exs b/installer/templates/phx_single/config/prod.exs
index d980b2a45..f6e8c82ed 100644
--- a/installer/templates/phx_single/config/prod.exs
+++ b/installer/templates/phx_single/config/prod.exs
@@ -2,11 +2,11 @@ import Config
 
 # For production, don't forget to configure the url host
 # to something meaningful, Phoenix uses this information
-# when generating URLs.<%= if @assets do %>
+# when generating URLs.<%= if @javascript or @css do %>
 
 # Note we also include the path to a cache manifest
 # containing the digested version of static files. This
-# manifest is generated by the `mix phx.digest` task,
+# manifest is generated by the `mix assets.deploy` task,
 # which you should run after static files are built and
 # before starting your production server.
 config :<%= @web_app_name %>, <%= @endpoint_module %>, cache_static_manifest: "priv/static/cache_manifest.json"<% end %><%= if @mailer do %>
diff --git a/installer/templates/phx_single/gitignore b/installer/templates/phx_single/gitignore
index 719487806..b32013e3a 100644
--- a/installer/templates/phx_single/gitignore
+++ b/installer/templates/phx_single/gitignore
@@ -24,7 +24,7 @@ erl_crash.dump
 
 # Ignore package tarball (built via "mix hex.build").
 <%= @app_name %>-*.tar
-<%= if @assets do %>
+<%= if @javascript or @css do %>
 # Ignore assets that are produced by build tools.
 /priv/static/assets/
 
diff --git a/installer/templates/phx_single/mix.exs b/installer/templates/phx_single/mix.exs
index 32322ea40..c0926b561 100644
--- a/installer/templates/phx_single/mix.exs
+++ b/installer/templates/phx_single/mix.exs
@@ -44,8 +44,8 @@ defmodule <%= @app_module %>.MixProject do
       {:phoenix_live_reload, "~> 1.2", only: :dev},
       {:phoenix_live_view, "~> 0.18.16"},
       {:floki, ">= 0.30.0", only: :test},<% end %><%= if @dashboard do %>
-      {:phoenix_live_dashboard, "~> 0.7.2"},<% end %><%= if @assets do %>
-      {:esbuild, "~> 0.5", runtime: Mix.env() == :dev},
+      {:phoenix_live_dashboard, "~> 0.7.2"},<% end %><%= if @javascript do %>
+      {:esbuild, "~> 0.5", runtime: Mix.env() == :dev},<% end %><%= if @css do %>
       {:tailwind, "~> 0.1.8", runtime: Mix.env() == :dev},<% end %><%= if @mailer do %>
       {:swoosh, "~> 1.3"},
       {:finch, "~> 0.13"},<% end %>
@@ -65,13 +65,13 @@ defmodule <%= @app_module %>.MixProject do
   # See the documentation for `Mix` for more info on aliases.
   defp aliases do
     [
-      setup: ["deps.get"<%= if @ecto do %>, "ecto.setup"<% end %><%= if @assets do %>, "assets.setup", "assets.build"<% end %>]<%= if @ecto do %>,
+      setup: ["deps.get"<%= if @ecto do %>, "ecto.setup"<% end %><%= if @asset_builders != [] do %>, "assets.setup", "assets.build"<% end %>]<%= if @ecto do %>,
       "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
       "ecto.reset": ["ecto.drop", "ecto.setup"],
-      test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"]<% end %><%= if @assets do %>,
-      "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
-      "assets.build": ["tailwind default", "esbuild default"],
-      "assets.deploy": ["tailwind default --minify", "esbuild default --minify", "phx.digest"]<% end %>
+      test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"]<% end %><%= if @asset_builders != [] do %>,
+      "assets.setup": <%= inspect Enum.map(@asset_builders, &"#{&1}.install --if-missing") %>,
+      "assets.build": <%= inspect Enum.map(@asset_builders, &"#{&1} default") %>,
+      "assets.deploy": <%= inspect Enum.map(@asset_builders, &"#{&1} default --minify") ++ ["phx.digest"] %><% end %>
     ]
   end
 end
diff --git a/installer/templates/phx_static/home.css b/installer/templates/phx_static/home.css
new file mode 100644
index 000000000..4746663b6
--- /dev/null
+++ b/installer/templates/phx_static/home.css
@@ -0,0 +1,1113 @@
+/* Default styling for the home page, this file can be deleted safely */
+
+/*
+! tailwindcss v3.2.4 | MIT License | https://tailwindcss.com
+*/
+
+/*
+1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
+2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
+*/
+
+*,
+::before,
+::after {
+  box-sizing: border-box;
+  /* 1 */
+  border-width: 0;
+  /* 2 */
+  border-style: solid;
+  /* 2 */
+  border-color: #e5e7eb;
+  /* 2 */
+}
+
+::before,
+::after {
+  --tw-content: '';
+}
+
+/*
+1. Use a consistent sensible line-height in all browsers.
+2. Prevent adjustments of font size after orientation changes in iOS.
+3. Use a more readable tab size.
+4. Use the user's configured `sans` font-family by default.
+5. Use the user's configured `sans` font-feature-settings by default.
+*/
+
+html {
+  line-height: 1.5;
+  /* 1 */
+  -webkit-text-size-adjust: 100%;
+  /* 2 */
+  -moz-tab-size: 4;
+  /* 3 */
+  -o-tab-size: 4;
+     tab-size: 4;
+  /* 3 */
+  font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+  /* 4 */
+  font-feature-settings: normal;
+  /* 5 */
+}
+
+/*
+1. Remove the margin in all browsers.
+2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
+*/
+
+body {
+  margin: 0;
+  /* 1 */
+  line-height: inherit;
+  /* 2 */
+}
+
+/*
+1. Add the correct height in Firefox.
+2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
+3. Ensure horizontal rules are visible by default.
+*/
+
+hr {
+  height: 0;
+  /* 1 */
+  color: inherit;
+  /* 2 */
+  border-top-width: 1px;
+  /* 3 */
+}
+
+/*
+Add the correct text decoration in Chrome, Edge, and Safari.
+*/
+
+abbr:where([title]) {
+  -webkit-text-decoration: underline dotted;
+          text-decoration: underline dotted;
+}
+
+/*
+Remove the default font size and weight for headings.
+*/
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+  font-size: inherit;
+  font-weight: inherit;
+}
+
+/*
+Reset links to optimize for opt-in styling instead of opt-out.
+*/
+
+a {
+  color: inherit;
+  text-decoration: inherit;
+}
+
+/*
+Add the correct font weight in Edge and Safari.
+*/
+
+b,
+strong {
+  font-weight: bolder;
+}
+
+/*
+1. Use the user's configured `mono` font family by default.
+2. Correct the odd `em` font sizing in all browsers.
+*/
+
+code,
+kbd,
+samp,
+pre {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+  /* 1 */
+  font-size: 1em;
+  /* 2 */
+}
+
+/*
+Add the correct font size in all browsers.
+*/
+
+small {
+  font-size: 80%;
+}
+
+/*
+Prevent `sub` and `sup` elements from affecting the line height in all browsers.
+*/
+
+sub,
+sup {
+  font-size: 75%;
+  line-height: 0;
+  position: relative;
+  vertical-align: baseline;
+}
+
+sub {
+  bottom: -0.25em;
+}
+
+sup {
+  top: -0.5em;
+}
+
+/*
+1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
+2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
+3. Remove gaps between table borders by default.
+*/
+
+table {
+  text-indent: 0;
+  /* 1 */
+  border-color: inherit;
+  /* 2 */
+  border-collapse: collapse;
+  /* 3 */
+}
+
+/*
+1. Change the font styles in all browsers.
+2. Remove the margin in Firefox and Safari.
+3. Remove default padding in all browsers.
+*/
+
+button,
+input,
+optgroup,
+select,
+textarea {
+  font-family: inherit;
+  /* 1 */
+  font-size: 100%;
+  /* 1 */
+  font-weight: inherit;
+  /* 1 */
+  line-height: inherit;
+  /* 1 */
+  color: inherit;
+  /* 1 */
+  margin: 0;
+  /* 2 */
+  padding: 0;
+  /* 3 */
+}
+
+/*
+Remove the inheritance of text transform in Edge and Firefox.
+*/
+
+button,
+select {
+  text-transform: none;
+}
+
+/*
+1. Correct the inability to style clickable types in iOS and Safari.
+2. Remove default button styles.
+*/
+
+button,
+[type='button'],
+[type='reset'],
+[type='submit'] {
+  -webkit-appearance: button;
+  /* 1 */
+  background-color: transparent;
+  /* 2 */
+  background-image: none;
+  /* 2 */
+}
+
+/*
+Use the modern Firefox focus style for all focusable elements.
+*/
+
+:-moz-focusring {
+  outline: auto;
+}
+
+/*
+Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
+*/
+
+:-moz-ui-invalid {
+  box-shadow: none;
+}
+
+/*
+Add the correct vertical alignment in Chrome and Firefox.
+*/
+
+progress {
+  vertical-align: baseline;
+}
+
+/*
+Correct the cursor style of increment and decrement buttons in Safari.
+*/
+
+::-webkit-inner-spin-button,
+::-webkit-outer-spin-button {
+  height: auto;
+}
+
+/*
+1. Correct the odd appearance in Chrome and Safari.
+2. Correct the outline style in Safari.
+*/
+
+[type='search'] {
+  -webkit-appearance: textfield;
+  /* 1 */
+  outline-offset: -2px;
+  /* 2 */
+}
+
+/*
+Remove the inner padding in Chrome and Safari on macOS.
+*/
+
+::-webkit-search-decoration {
+  -webkit-appearance: none;
+}
+
+/*
+1. Correct the inability to style clickable types in iOS and Safari.
+2. Change font properties to `inherit` in Safari.
+*/
+
+::-webkit-file-upload-button {
+  -webkit-appearance: button;
+  /* 1 */
+  font: inherit;
+  /* 2 */
+}
+
+/*
+Add the correct display in Chrome and Safari.
+*/
+
+summary {
+  display: list-item;
+}
+
+/*
+Removes the default spacing and border for appropriate elements.
+*/
+
+blockquote,
+dl,
+dd,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+hr,
+figure,
+p,
+pre {
+  margin: 0;
+}
+
+fieldset {
+  margin: 0;
+  padding: 0;
+}
+
+legend {
+  padding: 0;
+}
+
+ol,
+ul,
+menu {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+}
+
+/*
+Prevent resizing textareas horizontally by default.
+*/
+
+textarea {
+  resize: vertical;
+}
+
+/*
+1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
+2. Set the default placeholder color to the user's configured gray 400 color.
+*/
+
+input::-moz-placeholder, textarea::-moz-placeholder {
+  opacity: 1;
+  /* 1 */
+  color: #9ca3af;
+  /* 2 */
+}
+
+input::placeholder,
+textarea::placeholder {
+  opacity: 1;
+  /* 1 */
+  color: #9ca3af;
+  /* 2 */
+}
+
+/*
+Set the default cursor for buttons.
+*/
+
+button,
+[role="button"] {
+  cursor: pointer;
+}
+
+/*
+Make sure disabled buttons don't get the pointer cursor.
+*/
+
+:disabled {
+  cursor: default;
+}
+
+/*
+1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
+2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
+   This can trigger a poorly considered lint error in some tools but is included by design.
+*/
+
+img,
+svg,
+video,
+canvas,
+audio,
+iframe,
+embed,
+object {
+  display: block;
+  /* 1 */
+  vertical-align: middle;
+  /* 2 */
+}
+
+/*
+Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
+*/
+
+img,
+video {
+  max-width: 100%;
+  height: auto;
+}
+
+/* Make elements with the HTML hidden attribute stay hidden by default */
+
+[hidden] {
+  display: none;
+}
+
+[type='text'],[type='email'],[type='url'],[type='password'],[type='number'],[type='date'],[type='datetime-local'],[type='month'],[type='search'],[type='tel'],[type='time'],[type='week'],[multiple],textarea,select {
+  -webkit-appearance: none;
+     -moz-appearance: none;
+          appearance: none;
+  background-color: #fff;
+  border-color: #6b7280;
+  border-width: 1px;
+  border-radius: 0px;
+  padding-top: 0.5rem;
+  padding-right: 0.75rem;
+  padding-bottom: 0.5rem;
+  padding-left: 0.75rem;
+  font-size: 1rem;
+  line-height: 1.5rem;
+  --tw-shadow: 0 0 #0000;
+}
+
+[type='text']:focus, [type='email']:focus, [type='url']:focus, [type='password']:focus, [type='number']:focus, [type='date']:focus, [type='datetime-local']:focus, [type='month']:focus, [type='search']:focus, [type='tel']:focus, [type='time']:focus, [type='week']:focus, [multiple]:focus, textarea:focus, select:focus {
+  outline: 2px solid transparent;
+  outline-offset: 2px;
+  --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/);
+  --tw-ring-offset-width: 0px;
+  --tw-ring-offset-color: #fff;
+  --tw-ring-color: #2563eb;
+  --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
+  --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
+  box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
+  border-color: #2563eb;
+}
+
+input::-moz-placeholder, textarea::-moz-placeholder {
+  color: #6b7280;
+  opacity: 1;
+}
+
+input::placeholder,textarea::placeholder {
+  color: #6b7280;
+  opacity: 1;
+}
+
+::-webkit-datetime-edit-fields-wrapper {
+  padding: 0;
+}
+
+::-webkit-date-and-time-value {
+  min-height: 1.5em;
+}
+
+::-webkit-datetime-edit,::-webkit-datetime-edit-year-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-meridiem-field {
+  padding-top: 0;
+  padding-bottom: 0;
+}
+
+select {
+  background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
+  background-position: right 0.5rem center;
+  background-repeat: no-repeat;
+  background-size: 1.5em 1.5em;
+  padding-right: 2.5rem;
+  -webkit-print-color-adjust: exact;
+          print-color-adjust: exact;
+}
+
+[multiple] {
+  background-image: initial;
+  background-position: initial;
+  background-repeat: unset;
+  background-size: initial;
+  padding-right: 0.75rem;
+  -webkit-print-color-adjust: unset;
+          print-color-adjust: unset;
+}
+
+[type='checkbox'],[type='radio'] {
+  -webkit-appearance: none;
+     -moz-appearance: none;
+          appearance: none;
+  padding: 0;
+  -webkit-print-color-adjust: exact;
+          print-color-adjust: exact;
+  display: inline-block;
+  vertical-align: middle;
+  background-origin: border-box;
+  -webkit-user-select: none;
+     -moz-user-select: none;
+          user-select: none;
+  flex-shrink: 0;
+  height: 1rem;
+  width: 1rem;
+  color: #2563eb;
+  background-color: #fff;
+  border-color: #6b7280;
+  border-width: 1px;
+  --tw-shadow: 0 0 #0000;
+}
+
+[type='checkbox'] {
+  border-radius: 0px;
+}
+
+[type='radio'] {
+  border-radius: 100%;
+}
+
+[type='checkbox']:focus,[type='radio']:focus {
+  outline: 2px solid transparent;
+  outline-offset: 2px;
+  --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/);
+  --tw-ring-offset-width: 2px;
+  --tw-ring-offset-color: #fff;
+  --tw-ring-color: #2563eb;
+  --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
+  --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
+  box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
+}
+
+[type='checkbox']:checked,[type='radio']:checked {
+  border-color: transparent;
+  background-color: currentColor;
+  background-size: 100% 100%;
+  background-position: center;
+  background-repeat: no-repeat;
+}
+
+[type='checkbox']:checked {
+  background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
+}
+
+[type='radio']:checked {
+  background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e");
+}
+
+[type='checkbox']:checked:hover,[type='checkbox']:checked:focus,[type='radio']:checked:hover,[type='radio']:checked:focus {
+  border-color: transparent;
+  background-color: currentColor;
+}
+
+[type='checkbox']:indeterminate {
+  background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e");
+  border-color: transparent;
+  background-color: currentColor;
+  background-size: 100% 100%;
+  background-position: center;
+  background-repeat: no-repeat;
+}
+
+[type='checkbox']:indeterminate:hover,[type='checkbox']:indeterminate:focus {
+  border-color: transparent;
+  background-color: currentColor;
+}
+
+[type='file'] {
+  background: unset;
+  border-color: inherit;
+  border-width: 0;
+  border-radius: 0;
+  padding: 0;
+  font-size: unset;
+  line-height: inherit;
+}
+
+[type='file']:focus {
+  outline: 1px solid ButtonText;
+  outline: 1px auto -webkit-focus-ring-color;
+}
+
+*, ::before, ::after {
+  --tw-border-spacing-x: 0;
+  --tw-border-spacing-y: 0;
+  --tw-translate-x: 0;
+  --tw-translate-y: 0;
+  --tw-rotate: 0;
+  --tw-skew-x: 0;
+  --tw-skew-y: 0;
+  --tw-scale-x: 1;
+  --tw-scale-y: 1;
+  --tw-pan-x:  ;
+  --tw-pan-y:  ;
+  --tw-pinch-zoom:  ;
+  --tw-scroll-snap-strictness: proximity;
+  --tw-ordinal:  ;
+  --tw-slashed-zero:  ;
+  --tw-numeric-figure:  ;
+  --tw-numeric-spacing:  ;
+  --tw-numeric-fraction:  ;
+  --tw-ring-inset:  ;
+  --tw-ring-offset-width: 0px;
+  --tw-ring-offset-color: #fff;
+  --tw-ring-color: rgb(59 130 246 / 0.5);
+  --tw-ring-offset-shadow: 0 0 #0000;
+  --tw-ring-shadow: 0 0 #0000;
+  --tw-shadow: 0 0 #0000;
+  --tw-shadow-colored: 0 0 #0000;
+  --tw-blur:  ;
+  --tw-brightness:  ;
+  --tw-contrast:  ;
+  --tw-grayscale:  ;
+  --tw-hue-rotate:  ;
+  --tw-invert:  ;
+  --tw-saturate:  ;
+  --tw-sepia:  ;
+  --tw-drop-shadow:  ;
+  --tw-backdrop-blur:  ;
+  --tw-backdrop-brightness:  ;
+  --tw-backdrop-contrast:  ;
+  --tw-backdrop-grayscale:  ;
+  --tw-backdrop-hue-rotate:  ;
+  --tw-backdrop-invert:  ;
+  --tw-backdrop-opacity:  ;
+  --tw-backdrop-saturate:  ;
+  --tw-backdrop-sepia:  ;
+}
+
+::backdrop {
+  --tw-border-spacing-x: 0;
+  --tw-border-spacing-y: 0;
+  --tw-translate-x: 0;
+  --tw-translate-y: 0;
+  --tw-rotate: 0;
+  --tw-skew-x: 0;
+  --tw-skew-y: 0;
+  --tw-scale-x: 1;
+  --tw-scale-y: 1;
+  --tw-pan-x:  ;
+  --tw-pan-y:  ;
+  --tw-pinch-zoom:  ;
+  --tw-scroll-snap-strictness: proximity;
+  --tw-ordinal:  ;
+  --tw-slashed-zero:  ;
+  --tw-numeric-figure:  ;
+  --tw-numeric-spacing:  ;
+  --tw-numeric-fraction:  ;
+  --tw-ring-inset:  ;
+  --tw-ring-offset-width: 0px;
+  --tw-ring-offset-color: #fff;
+  --tw-ring-color: rgb(59 130 246 / 0.5);
+  --tw-ring-offset-shadow: 0 0 #0000;
+  --tw-ring-shadow: 0 0 #0000;
+  --tw-shadow: 0 0 #0000;
+  --tw-shadow-colored: 0 0 #0000;
+  --tw-blur:  ;
+  --tw-brightness:  ;
+  --tw-contrast:  ;
+  --tw-grayscale:  ;
+  --tw-hue-rotate:  ;
+  --tw-invert:  ;
+  --tw-saturate:  ;
+  --tw-sepia:  ;
+  --tw-drop-shadow:  ;
+  --tw-backdrop-blur:  ;
+  --tw-backdrop-brightness:  ;
+  --tw-backdrop-contrast:  ;
+  --tw-backdrop-grayscale:  ;
+  --tw-backdrop-hue-rotate:  ;
+  --tw-backdrop-invert:  ;
+  --tw-backdrop-opacity:  ;
+  --tw-backdrop-saturate:  ;
+  --tw-backdrop-sepia:  ;
+}
+
+.static {
+  position: static;
+}
+
+.fixed {
+  position: fixed;
+}
+
+.absolute {
+  position: absolute;
+}
+
+.relative {
+  position: relative;
+}
+
+.inset-0 {
+  top: 0px;
+  right: 0px;
+  bottom: 0px;
+  left: 0px;
+}
+
+.inset-y-0 {
+  top: 0px;
+  bottom: 0px;
+}
+
+.right-0 {
+  right: 0px;
+}
+
+.left-\[40rem\] {
+  left: 40rem;
+}
+
+.z-0 {
+  z-index: 0;
+}
+
+.mx-auto {
+  margin-left: auto;
+  margin-right: auto;
+}
+
+.-mx-2 {
+  margin-left: -0.5rem;
+  margin-right: -0.5rem;
+}
+
+.-my-0\.5 {
+  margin-top: -0.125rem;
+  margin-bottom: -0.125rem;
+}
+
+.-my-0 {
+  margin-top: -0px;
+  margin-bottom: -0px;
+}
+
+.mt-10 {
+  margin-top: 2.5rem;
+}
+
+.ml-3 {
+  margin-left: 0.75rem;
+}
+
+.mt-4 {
+  margin-top: 1rem;
+}
+
+.flex {
+  display: flex;
+}
+
+.inline-flex {
+  display: inline-flex;
+}
+
+.grid {
+  display: grid;
+}
+
+.contents {
+  display: contents;
+}
+
+.hidden {
+  display: none;
+}
+
+.h-6 {
+  height: 1.5rem;
+}
+
+.h-full {
+  height: 100%;
+}
+
+.h-12 {
+  height: 3rem;
+}
+
+.h-4 {
+  height: 1rem;
+}
+
+.w-full {
+  width: 100%;
+}
+
+.w-6 {
+  width: 1.5rem;
+}
+
+.w-4 {
+  width: 1rem;
+}
+
+.max-w-2xl {
+  max-width: 42rem;
+}
+
+.max-w-xl {
+  max-width: 36rem;
+}
+
+.grid-cols-1 {
+  grid-template-columns: repeat(1, minmax(0, 1fr));
+}
+
+.items-center {
+  align-items: center;
+}
+
+.justify-between {
+  justify-content: space-between;
+}
+
+.gap-4 {
+  gap: 1rem;
+}
+
+.gap-3 {
+  gap: 0.75rem;
+}
+
+.gap-x-6 {
+  -moz-column-gap: 1.5rem;
+       column-gap: 1.5rem;
+}
+
+.gap-y-4 {
+  row-gap: 1rem;
+}
+
+.rounded-full {
+  border-radius: 9999px;
+}
+
+.rounded-lg {
+  border-radius: 0.5rem;
+}
+
+.rounded-2xl {
+  border-radius: 1rem;
+}
+
+.border-b {
+  border-bottom-width: 1px;
+}
+
+.border-zinc-100 {
+  --tw-border-opacity: 1;
+  border-color: rgb(244 244 245 / var(--tw-border-opacity));
+}
+
+.bg-brand\/5 {
+  background-color: rgb(253 79 0 / 0.05);
+}
+
+.bg-zinc-100 {
+  --tw-bg-opacity: 1;
+  background-color: rgb(244 244 245 / var(--tw-bg-opacity));
+}
+
+.bg-white {
+  --tw-bg-opacity: 1;
+  background-color: rgb(255 255 255 / var(--tw-bg-opacity));
+}
+
+.bg-zinc-50 {
+  --tw-bg-opacity: 1;
+  background-color: rgb(250 250 250 / var(--tw-bg-opacity));
+}
+
+.fill-zinc-400 {
+  fill: #a1a1aa;
+}
+
+.px-4 {
+  padding-left: 1rem;
+  padding-right: 1rem;
+}
+
+.py-3 {
+  padding-top: 0.75rem;
+  padding-bottom: 0.75rem;
+}
+
+.px-2 {
+  padding-left: 0.5rem;
+  padding-right: 0.5rem;
+}
+
+.py-1 {
+  padding-top: 0.25rem;
+  padding-bottom: 0.25rem;
+}
+
+.py-20 {
+  padding-top: 5rem;
+  padding-bottom: 5rem;
+}
+
+.py-10 {
+  padding-top: 2.5rem;
+  padding-bottom: 2.5rem;
+}
+
+.px-6 {
+  padding-left: 1.5rem;
+  padding-right: 1.5rem;
+}
+
+.py-4 {
+  padding-top: 1rem;
+  padding-bottom: 1rem;
+}
+
+.py-0\.5 {
+  padding-top: 0.125rem;
+  padding-bottom: 0.125rem;
+}
+
+.py-0 {
+  padding-top: 0px;
+  padding-bottom: 0px;
+}
+
+.text-\[0\.8125rem\] {
+  font-size: 0.8125rem;
+}
+
+.text-sm {
+  font-size: 0.875rem;
+  line-height: 1.25rem;
+}
+
+.text-\[2rem\] {
+  font-size: 2rem;
+}
+
+.text-base {
+  font-size: 1rem;
+  line-height: 1.5rem;
+}
+
+.font-medium {
+  font-weight: 500;
+}
+
+.font-semibold {
+  font-weight: 600;
+}
+
+.leading-6 {
+  line-height: 1.5rem;
+}
+
+.leading-10 {
+  line-height: 2.5rem;
+}
+
+.leading-7 {
+  line-height: 1.75rem;
+}
+
+.tracking-tighter {
+  letter-spacing: -0.05em;
+}
+
+.text-brand {
+  --tw-text-opacity: 1;
+  color: rgb(253 79 0 / var(--tw-text-opacity));
+}
+
+.text-zinc-900 {
+  --tw-text-opacity: 1;
+  color: rgb(24 24 27 / var(--tw-text-opacity));
+}
+
+.text-zinc-600 {
+  --tw-text-opacity: 1;
+  color: rgb(82 82 91 / var(--tw-text-opacity));
+}
+
+.text-zinc-700 {
+  --tw-text-opacity: 1;
+  color: rgb(63 63 70 / var(--tw-text-opacity));
+}
+
+.antialiased {
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+
+.transition {
+  transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter;
+  transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
+  transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter;
+  transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+  transition-duration: 150ms;
+}
+
+/* This file is for your main application CSS */
+
+.hover\:bg-zinc-200\/80:hover {
+  background-color: rgb(228 228 231 / 0.8);
+}
+
+.hover\:bg-zinc-50:hover {
+  --tw-bg-opacity: 1;
+  background-color: rgb(250 250 250 / var(--tw-bg-opacity));
+}
+
+.hover\:text-zinc-700:hover {
+  --tw-text-opacity: 1;
+  color: rgb(63 63 70 / var(--tw-text-opacity));
+}
+
+.hover\:text-zinc-900:hover {
+  --tw-text-opacity: 1;
+  color: rgb(24 24 27 / var(--tw-text-opacity));
+}
+
+.active\:text-zinc-900\/70:active {
+  color: rgb(24 24 27 / 0.7);
+}
+
+.group:hover .group-hover\:bg-zinc-100 {
+  --tw-bg-opacity: 1;
+  background-color: rgb(244 244 245 / var(--tw-bg-opacity));
+}
+
+.group:hover .group-hover\:fill-zinc-600 {
+  fill: #52525b;
+}
+
+@media (min-width: 640px) {
+  .sm\:w-auto {
+    width: auto;
+  }
+
+  .sm\:grid-cols-3 {
+    grid-template-columns: repeat(3, minmax(0, 1fr));
+  }
+
+  .sm\:grid-cols-2 {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .sm\:flex-col {
+    flex-direction: column;
+  }
+
+  .sm\:px-6 {
+    padding-left: 1.5rem;
+    padding-right: 1.5rem;
+  }
+
+  .sm\:py-28 {
+    padding-top: 7rem;
+    padding-bottom: 7rem;
+  }
+
+  .sm\:py-6 {
+    padding-top: 1.5rem;
+    padding-bottom: 1.5rem;
+  }
+
+  .group:hover .sm\:group-hover\:scale-105 {
+    --tw-scale-x: 1.05;
+    --tw-scale-y: 1.05;
+    transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
+  }
+}
+
+@media (min-width: 1024px) {
+  .lg\:mx-0 {
+    margin-left: 0px;
+    margin-right: 0px;
+  }
+
+  .lg\:block {
+    display: block;
+  }
+
+  .lg\:px-8 {
+    padding-left: 2rem;
+    padding-right: 2rem;
+  }
+}
+
+@media (min-width: 1280px) {
+  .xl\:left-\[50rem\] {
+    left: 50rem;
+  }
+
+  .xl\:py-32 {
+    padding-top: 8rem;
+    padding-bottom: 8rem;
+  }
+
+  .xl\:px-28 {
+    padding-left: 7rem;
+    padding-right: 7rem;
+  }
+}
diff --git a/installer/templates/phx_umbrella/apps/app_name_web/config/config.exs b/installer/templates/phx_umbrella/apps/app_name_web/config/config.exs
index 9d87d39ce..b6e0fa984 100644
--- a/installer/templates/phx_umbrella/apps/app_name_web/config/config.exs
+++ b/installer/templates/phx_umbrella/apps/app_name_web/config/config.exs
@@ -12,7 +12,7 @@ config :<%= @web_app_name %>, <%= @endpoint_module %>,
     layout: false
   ],
   pubsub_server: <%= @app_module %>.PubSub,
-  live_view: [signing_salt: "<%= @lv_signing_salt %>"]<%= if @assets do %>
+  live_view: [signing_salt: "<%= @lv_signing_salt %>"]<%= if @javascript do %>
 
 # Configure esbuild (the version is required)
 config :esbuild,
@@ -22,7 +22,7 @@ config :esbuild,
       ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
     cd: Path.expand("../apps/<%= @web_app_name %>/assets", __DIR__),
     env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
-  ]
+  ]<% end %><%= if @css do %>
 
 # Configure tailwind (the version is required)
 config :tailwind,
diff --git a/installer/templates/phx_umbrella/apps/app_name_web/config/dev.exs b/installer/templates/phx_umbrella/apps/app_name_web/config/dev.exs
index 9757b1eba..a01f32588 100644
--- a/installer/templates/phx_umbrella/apps/app_name_web/config/dev.exs
+++ b/installer/templates/phx_umbrella/apps/app_name_web/config/dev.exs
@@ -12,9 +12,9 @@ config :<%= @web_app_name %>, <%= @endpoint_module %>,
   code_reloader: true,
   debug_errors: true,
   secret_key_base: "<%= @secret_key_base_dev %>",
-  watchers: <%= if @assets do %>[
-    esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]},
-    tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]}
+  watchers: <%= if @javascript or @css do %>[<%= if @javascript do %>
+    esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}<%= if @css, do: "," %><% end %><%= if @css do %>
+    tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]}<% end %>
   ]<% else %>[]<% end %>
 
 # ## SSL Support
diff --git a/installer/templates/phx_umbrella/apps/app_name_web/gitignore b/installer/templates/phx_umbrella/apps/app_name_web/gitignore
index c5eb50ab6..4f23a32d9 100644
--- a/installer/templates/phx_umbrella/apps/app_name_web/gitignore
+++ b/installer/templates/phx_umbrella/apps/app_name_web/gitignore
@@ -24,7 +24,7 @@ erl_crash.dump
 
 # Ignore package tarball (built via "mix hex.build").
 <%= @web_app_name %>-*.tar
-<%= if @assets do %>
+<%= if @javascript or @css do %>
 # Ignore assets that are produced by build tools.
 /priv/static/assets/
 
diff --git a/installer/templates/phx_umbrella/apps/app_name_web/mix.exs b/installer/templates/phx_umbrella/apps/app_name_web/mix.exs
index a523203d1..402f2e155 100644
--- a/installer/templates/phx_umbrella/apps/app_name_web/mix.exs
+++ b/installer/templates/phx_umbrella/apps/app_name_web/mix.exs
@@ -42,8 +42,8 @@ defmodule <%= @web_namespace %>.MixProject do
       {:phoenix_live_reload, "~> 1.2", only: :dev},
       {:phoenix_live_view, "~> 0.18.16"},
       {:floki, ">= 0.30.0", only: :test},<% end %><%= if @dashboard do %>
-      {:phoenix_live_dashboard, "~> 0.7.2"},<% end %><%= if @assets do %>
-      {:esbuild, "~> 0.5", runtime: Mix.env() == :dev},
+      {:phoenix_live_dashboard, "~> 0.7.2"},<% end %><%= if @javascript do %>
+      {:esbuild, "~> 0.5", runtime: Mix.env() == :dev},<% end %><%= if @css do %>
       {:tailwind, "~> 0.1.8", runtime: Mix.env() == :dev},<% end %>
       {:telemetry_metrics, "~> 0.6"},
       {:telemetry_poller, "~> 1.0"},<%= if @gettext do %>
@@ -59,11 +59,11 @@ defmodule <%= @web_namespace %>.MixProject do
   # See the documentation for `Mix` for more info on aliases.
   defp aliases do
     [
-      setup: ["deps.get"<%= if @assets do %>, "assets.setup", "assets.build"<% end %>]<%= if @ecto do %>,
-      test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"]<% end %><%= if @assets do %>,
-      "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
-      "assets.build": ["tailwind default", "esbuild default"],
-      "assets.deploy": ["tailwind default --minify", "esbuild default --minify", "phx.digest"]<% end %>
+      setup: ["deps.get"<%= if @asset_builders != [] do %>, "assets.setup", "assets.build"<% end %>]<%= if @ecto do %>,
+      test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"]<% end %><%= if @asset_builders != [] do %>,
+      "assets.setup": <%= inspect Enum.map(@asset_builders, &"#{&1}.install --if-missing") %>,
+      "assets.build": <%= inspect Enum.map(@asset_builders, &"#{&1} default") %>,
+      "assets.deploy": <%= inspect Enum.map(@asset_builders, &"#{&1} default --minify") ++ ["phx.digest"] %><% end %>
     ]
   end
 end
diff --git a/installer/templates/phx_web/components/core_components.ex b/installer/templates/phx_web/components/core_components.ex
index 15edda02a..e485078e0 100644
--- a/installer/templates/phx_web/components/core_components.ex
+++ b/installer/templates/phx_web/components/core_components.ex
@@ -3,8 +3,10 @@ defmodule <%= @web_namespace %>.CoreComponents do
   Provides core UI components.
 
   The components in this module use Tailwind CSS, a utility-first CSS framework.
-  See the [Tailwind CSS documentation](https://tailwindcss.com) to learn how to
-  customize the generated components in this module.
+  You are welcome to change these components in any way according to the needs
+  of your application. See the [Tailwind CSS documentation](https://tailwindcss.com)
+  to learn how to customize the generated components in this module. Alternatively,
+  feel free to swap to another framework altogether.
 
   Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage.
   """
@@ -19,37 +21,28 @@ defmodule <%= @web_namespace %>.CoreComponents do
   ## Examples
 
       <.modal id="confirm-modal">
-        Are you sure?
-        <:confirm>OK</:confirm>
-        <:cancel>Cancel</:cancel>
+        This is a modal.
       </.modal>
 
-  JS commands may be passed to the `:on_cancel` and `on_confirm` attributes
-  for the caller to react to each button press, for example:
+  JS commands may be passed to the `:on_cancel` to configure
+  the closing/cancel event, for example:
 
-      <.modal id="confirm" on_confirm={JS.push("delete")} on_cancel={JS.navigate(~p"/posts")}>
-        Are you sure you?
-        <:confirm>OK</:confirm>
-        <:cancel>Cancel</:cancel>
+      <.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}>
+        This is another modal.
       </.modal>
+
   """
   attr :id, :string, required: true
   attr :show, :boolean, default: false
   attr :on_cancel, JS, default: %JS{}
-  attr :on_confirm, JS, default: %JS{}
-
   slot :inner_block, required: true
-  slot :title
-  slot :subtitle
-  slot :confirm
-  slot :cancel
 
   def modal(assigns) do
     ~H"""
     <div
       id={@id}
       phx-mounted={@show && show_modal(@id)}
-      phx-remove={hide_modal(@id)}
+      phx-remove={hide_modal(@on_cancel, @id)}
       class="relative z-50 hidden"
     >
       <div id={"#{@id}-bg"} class="fixed inset-0 bg-zinc-50/90 transition-opacity" aria-hidden="true" />
@@ -65,15 +58,14 @@ defmodule <%= @web_namespace %>.CoreComponents do
           <div class="w-full max-w-3xl p-4 sm:p-6 lg:py-8">
             <.focus_wrap
               id={"#{@id}-container"}
-              phx-mounted={@show && show_modal(@id)}
-              phx-window-keydown={hide_modal(@on_cancel, @id)}
+              phx-window-keydown={JS.exec("phx-remove", to: "##{@id}")}
               phx-key="escape"
-              phx-click-away={hide_modal(@on_cancel, @id)}
+              phx-click-away={JS.exec("phx-remove", to: "##{@id}")}
               class="hidden relative rounded-2xl bg-white p-14 shadow-lg shadow-zinc-700/10 ring-1 ring-zinc-700/10 transition"
             >
               <div class="absolute top-6 right-5">
                 <button
-                  phx-click={hide_modal(@on_cancel, @id)}
+                  phx-click={JS.exec("phx-remove", to: "##{@id}")}
                   type="button"
                   class="-m-3 flex-none p-3 opacity-20 hover:opacity-40"
                   aria-label=<%= if @gettext do %>{gettext("close")}<% else %>"close"<% end %>
@@ -82,37 +74,7 @@ defmodule <%= @web_namespace %>.CoreComponents do
                 </button>
               </div>
               <div id={"#{@id}-content"}>
-                <header :if={@title != []}>
-                  <h1 id={"#{@id}-title"} class="text-lg font-semibold leading-8 text-zinc-800">
-                    <%%= render_slot(@title) %>
-                  </h1>
-                  <p
-                    :if={@subtitle != []}
-                    id={"#{@id}-description"}
-                    class="mt-2 text-sm leading-6 text-zinc-600"
-                  >
-                    <%%= render_slot(@subtitle) %>
-                  </p>
-                </header>
                 <%%= render_slot(@inner_block) %>
-                <div :if={@confirm != [] or @cancel != []} class="ml-6 mb-4 flex items-center gap-5">
-                  <.button
-                    :for={confirm <- @confirm}
-                    id={"#{@id}-confirm"}
-                    phx-click={@on_confirm}
-                    phx-disable-with
-                    class="py-2 px-3"
-                  >
-                    <%%= render_slot(confirm) %>
-                  </.button>
-                  <.link
-                    :for={cancel <- @cancel}
-                    phx-click={hide_modal(@on_cancel, @id)}
-                    class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
-                  >
-                    <%%= render_slot(cancel) %>
-                  </.link>
-                </div>
               </div>
             </.focus_wrap>
           </div>
@@ -134,8 +96,6 @@ defmodule <%= @web_namespace %>.CoreComponents do
   attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
   attr :title, :string, default: nil
   attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
-  attr :autoshow, :boolean, default: true, doc: "whether to auto show the flash on mount"
-  attr :close, :boolean, default: true, doc: "whether the flash can be closed"
   attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
 
   slot :inner_block, doc: "the optional inner block that renders the flash message"
@@ -145,28 +105,22 @@ defmodule <%= @web_namespace %>.CoreComponents do
     <div
       :if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
       id={@id}
-      phx-mounted={@autoshow && show("##{@id}")}
       phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
       role="alert"
       class={[
-        "fixed hidden top-2 right-2 w-80 sm:w-96 z-50 rounded-lg p-3 shadow-md shadow-zinc-900/5 ring-1",
+        "fixed hidden top-2 right-2 w-80 sm:w-96 z-50 rounded-lg p-3 ring-1",
         @kind == :info && "bg-emerald-50 text-emerald-800 ring-emerald-500 fill-cyan-900",
-        @kind == :error && "bg-rose-50 p-3 text-rose-900 shadow-md ring-rose-500 fill-rose-900"
+        @kind == :error && "bg-rose-50 text-rose-900 shadow-md ring-rose-500 fill-rose-900"
       ]}
       {@rest}
     >
-      <p :if={@title} class="flex items-center gap-1.5 text-[0.8125rem] font-semibold leading-6">
+      <p :if={@title} class="flex items-center gap-1.5 text-sm font-semibold leading-6">
         <.icon :if={@kind == :info} name="hero-information-circle-mini" class="w-4 h-4" />
         <.icon :if={@kind == :error} name="hero-exclamation-circle-mini" class="w-4 h-4" />
         <%%= @title %>
       </p>
-      <p class="mt-2 text-[0.8125rem] leading-5"><%%= msg %></p>
-      <button
-        :if={@close}
-        type="button"
-        class="group absolute top-2 right-1 p-2"
-        aria-label=<%= if @gettext do %>{gettext("close")}<% else %>"close"<% end %>
-      >
+      <p class="mt-2 text-sm leading-5"><%%= msg %></p>
+      <button type="button" class="group absolute top-1 right-1 p-2" aria-label=<%= if @gettext do %>{gettext("close")}<% else %>"close"<% end %>>
         <.icon name="hero-x-mark-solid" class="w-5 h-5 opacity-40 group-hover:opacity-70" />
       </button>
     </div>
@@ -190,10 +144,9 @@ defmodule <%= @web_namespace %>.CoreComponents do
       id="disconnected"
       kind={:error}
       title="We can't find the internet"
-      close={false}
-      autoshow={false}
       phx-disconnected={show("#disconnected")}
       phx-connected={hide("#disconnected")}
+      hidden
     >
       Attempting to reconnect <.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" />
     </.flash>
@@ -323,7 +276,7 @@ defmodule <%= @web_namespace %>.CoreComponents do
           name={@name}
           value="true"
           checked={@checked}
-          class="rounded border-zinc-300 text-zinc-900 focus:ring-zinc-900"
+          class="rounded border-zinc-300 text-zinc-900 focus:ring-0"
           {@rest}
         />
         <%%= @label %>
@@ -340,7 +293,7 @@ defmodule <%= @web_namespace %>.CoreComponents do
       <select
         id={@id}
         name={@name}
-        class="mt-1 block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-zinc-500 focus:border-zinc-500 sm:text-sm"
+        class="mt-1 block w-full border border-gray-300 bg-white rounded-md shadow-sm focus:ring-0 focus:border-zinc-400 sm:text-sm"
         multiple={@multiple}
         {@rest}
       >
@@ -360,11 +313,11 @@ defmodule <%= @web_namespace %>.CoreComponents do
         id={@id || @name}
         name={@name}
         class={[
-          "mt-2 block min-h-[6rem] w-full rounded-lg border-zinc-300 py-[7px] px-[11px]",
-          "text-zinc-900 focus:border-zinc-400 focus:outline-none focus:ring-4 focus:ring-zinc-800/5 sm:text-sm sm:leading-6",
-          "phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400 phx-no-feedback:focus:ring-zinc-800/5",
-          "border-zinc-300 focus:border-zinc-400 focus:ring-zinc-800/5",
-          @errors != [] && "border-rose-400 focus:border-rose-400 focus:ring-rose-400/10"
+          "mt-2 block min-h-[6rem] w-full rounded-lg",
+          "text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6",
+          "phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400",
+          "border-zinc-300 focus:border-zinc-400",
+          @errors != [] && "border-rose-400 focus:border-rose-400"
         ]}
         {@rest}
       ><%%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
@@ -373,6 +326,7 @@ defmodule <%= @web_namespace %>.CoreComponents do
     """
   end
 
+  # All other inputs text, datetime-local, url, password, etc. are handled here...
   def input(assigns) do
     ~H"""
     <div phx-feedback-for={@name}>
@@ -383,11 +337,11 @@ defmodule <%= @web_namespace %>.CoreComponents do
         id={@id || @name}
         value={Phoenix.HTML.Form.normalize_value(@type, @value)}
         class={[
-          "mt-2 block w-full rounded-lg border-zinc-300 py-[7px] px-[11px]",
-          "text-zinc-900 focus:outline-none focus:ring-4 sm:text-sm sm:leading-6",
-          "phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400 phx-no-feedback:focus:ring-zinc-800/5",
-          "border-zinc-300 focus:border-zinc-400 focus:ring-zinc-800/5",
-          @errors != [] && "border-rose-400 focus:border-rose-400 focus:ring-rose-400/10"
+          "mt-2 block w-full rounded-lg border-zinc-300",
+          "text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6",
+          "phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400",
+          "border-zinc-300 focus:border-zinc-400",
+          @errors != [] && "border-rose-400 focus:border-rose-400"
         ]}
         {@rest}
       />
@@ -586,15 +540,13 @@ defmodule <%= @web_namespace %>.CoreComponents do
   You can customize the size and colors of the icons by setting
   width, height, and background color classes.
 
-  Icons are extracted from your `priv/hero_icons` directory and bundled
+  Icons are extracted from your `assets/vendor/heroicons` directory and bundled
   within your compiled app.css by the plugin in your `assets/tailwind.config.js`.
 
   ## Examples
 
-      <.icon name="hero-cake" />
-      <.icon name="hero-cake-solid" />
-      <.icon name="hero-cake-mini" />
-      <.icon name="hero-bolt" class="bg-blue-500 w-10 h-10" />
+      <.icon name="hero-x-mark-solid" />
+      <.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" />
   """
   attr :name, :string, required: true
   attr :class, :string, default: nil
diff --git a/installer/templates/phx_web/components/layouts/app.html.heex b/installer/templates/phx_web/components/layouts/app.html.heex
index 52189cb69..a12b2a8a2 100644
--- a/installer/templates/phx_web/components/layouts/app.html.heex
+++ b/installer/templates/phx_web/components/layouts/app.html.heex
@@ -2,12 +2,7 @@
   <div class="flex items-center justify-between border-b border-zinc-100 py-3">
     <div class="flex items-center gap-4">
       <a href="/">
-        <svg viewBox="0 0 71 48" class="h-6" aria-hidden="true">
-          <path
-            d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.043.03a2.96 2.96 0 0 0 .04-.029c-.038-.117-.107-.12-.197-.054l.122.107c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728.374.388.763.768 1.182 1.106 1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zm17.29-19.32c0-.023.001-.045.003-.068l-.006.006.006-.006-.036-.004.021.018.012.053Zm-20 14.744a7.61 7.61 0 0 0-.072-.041.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zm-.072-.041-.008-.034-.008.01.008-.01-.022-.006.005.026.024.014Z"
-            fill="#FD4F00"
-          />
-        </svg>
+        <img src={~p"/images/logo.svg"} class="h-6" />
       </a>
       <p class="rounded-full bg-brand/5 px-2 text-[0.8125rem] font-medium leading-6 text-brand">
         v<%%= Application.spec(:phoenix, :vsn) %>
diff --git a/installer/templates/phx_web/controllers/page_html/home.html.heex b/installer/templates/phx_web/controllers/page_html/home.html.heex
index 3953540b3..022ad1bb9 100644
--- a/installer/templates/phx_web/controllers/page_html/home.html.heex
+++ b/installer/templates/phx_web/controllers/page_html/home.html.heex
@@ -1,4 +1,5 @@
-<.flash_group flash={@flash} />
+<%= unless @css do %><link phx-track-static rel="stylesheet" href={~p"/assets/home.css"} />
+<% end %><.flash_group flash={@flash} />
 <div class="fixed inset-y-0 right-0 left-[40rem] hidden lg:block xl:left-[50rem] z-0">
   <svg
     viewBox="0 0 1480 957"
diff --git a/lib/phoenix/code_reloader.ex b/lib/phoenix/code_reloader.ex
index 723aa3bad..9aeb034fe 100644
--- a/lib/phoenix/code_reloader.ex
+++ b/lib/phoenix/code_reloader.ex
@@ -84,8 +84,6 @@ defmodule Phoenix.CodeReloader do
   end
 
   defp template(output) do
-    {error, headline} = get_error_details(output)
-
     """
     <!DOCTYPE html>
     <html>
@@ -212,8 +210,7 @@ defmodule Phoenix.CodeReloader do
         }
 
         .exception-info > .error,
-        .exception-info > .subtext,
-        .exception-info > .title {
+        .exception-info > .subtext {
             margin: 0;
             padding: 0;
         }
@@ -230,13 +227,6 @@ defmodule Phoenix.CodeReloader do
             color: #{@style.accent};
         }
 
-        .exception-info > .title {
-            font-size: #{:math.pow(1.2, 4)}em;
-            line-height: 1.4;
-            font-weight: 300;
-            color: #{@style.primary};
-        }
-
         @media (max-width: 768px) {
             .exception-info > .title {
                 font-size: #{:math.pow(1.15, 4)}em;
@@ -253,6 +243,7 @@ defmodule Phoenix.CodeReloader do
             margin: 0;
             font-size: .85em;
             line-height: 1.6;
+            white-space: pre-wrap;
         }
         </style>
     </head>
@@ -260,8 +251,7 @@ defmodule Phoenix.CodeReloader do
         <div class="heading-block">
             <aside class="exception-logo"></aside>
             <header class="exception-info">
-                <h5 class="error">#{error}</h5>
-                <h1 class="title">#{headline}</h1>
+                <h5 class="error">Compilation error</h5>
                 <h5 class="subtext">Console output is shown below.</h5>
             </header>
         </div>
@@ -278,11 +268,4 @@ defmodule Phoenix.CodeReloader do
     |> String.trim
     |> Plug.HTML.html_escape
   end
-
-  defp get_error_details(output) do
-    case Regex.run(~r/(?:\n|^)\*\* \(([^ ]+)\) (.*)(?:\n|$)/, output) do
-      [_, error, headline] -> {error, format_output(headline)}
-      _ -> {"CompileError", "Compilation error"}
-    end
-  end
 end
diff --git a/lib/phoenix/code_reloader/server.ex b/lib/phoenix/code_reloader/server.ex
index 6806c3881..64f467b46 100644
--- a/lib/phoenix/code_reloader/server.ex
+++ b/lib/phoenix/code_reloader/server.ex
@@ -256,15 +256,13 @@ defmodule Phoenix.CodeReloader.Server do
     # assets in priv are copied to the build directory.
     Mix.Project.build_structure(config)
     args = ["--purge-consolidation-path-if-stale", consolidation_path]
-    results = Enum.map(compilers, &Mix.Task.run("compile.#{&1}", args))
+    result = run_compilers(compilers, args, [])
 
-    # Results are either {:ok, _} | {:error, _}, {:noop, _} or
-    # :ok | :error | :noop. So we use proplists to do the unwrapping.
     cond do
-      :proplists.get_value(:error, results, false) ->
+      result == :error ->
         exit({:shutdown, 1})
 
-      :proplists.get_value(:ok, results, false) && config[:consolidate_protocols] ->
+      result == :ok && config[:consolidate_protocols] ->
         Mix.Task.reenable("compile.protocols")
         Mix.Task.run("compile.protocols", [])
         :ok
@@ -299,4 +297,38 @@ defmodule Phoenix.CodeReloader.Server do
       Process.exit(proxy_gl, :kill)
     end
   end
+
+  defp run_compilers([compiler | compilers], args, acc) do
+    with {status, diagnostics} <- Mix.Task.run("compile.#{compiler}", args) do
+      # Diagnostics are written to stderr and therefore not captured,
+      # so we print them to the group leader here
+      Enum.each(diagnostics, &print_diagnostic/1)
+      {status, diagnostics}
+    end
+    |> case do
+      :error -> :error
+      {:error, _} -> :error
+      result -> run_compilers(compilers, args, [result | acc])
+    end
+  end
+
+  defp run_compilers([], _args, results) do
+    if :proplists.get_value(:ok, results, false) do
+      :ok
+    else
+      :noop
+    end
+  end
+
+  defp print_diagnostic(%{severity: :error, message: "**" <> _ = message}) do
+    IO.write("\n#{message}\n")
+  end
+
+  defp print_diagnostic(%{severity: severity, message: message, file: file, position: position}) do
+    IO.write("\n#{severity}: #{message}\n  #{Path.relative_to_cwd(file)}#{position(position)}\n")
+  end
+
+  defp position({line, col}), do: ":#{line}:#{col}"
+  defp position(line) when is_integer(line) and line > 0, do: ":#{line}"
+  defp position(_), do: ""
 end
diff --git a/lib/phoenix/controller.ex b/lib/phoenix/controller.ex
index b4eb51321..d1d029c48 100644
--- a/lib/phoenix/controller.ex
+++ b/lib/phoenix/controller.ex
@@ -166,7 +166,12 @@ defmodule Phoenix.Controller do
   ### Guards
 
   `plug/2` in controllers supports guards, allowing a developer to configure
-  a plug to only run in some particular action:
+  a plug to only run in some particular action.
+
+      plug :do_something when action in [:show, :edit]
+
+  Due to operator precedence in Elixir, if the second argument is a keyword list,
+  we need to wrap the keyword in `[...]` when using `when`:
 
       plug :authenticate, [usernames: ["jose", "eric", "sonny"]] when action in [:show, :edit]
       plug :authenticate, [usernames: ["admin"]] when not action in [:index]
diff --git a/lib/phoenix/endpoint.ex b/lib/phoenix/endpoint.ex
index 34f63cd56..479df96ec 100644
--- a/lib/phoenix/endpoint.ex
+++ b/lib/phoenix/endpoint.ex
@@ -817,6 +817,10 @@ defmodule Phoenix.Endpoint do
           config in runtime `{MyAppWeb.Auth, :get_session_config, []}`. Otherwise
           the session will be `nil`.
 
+          `session_config` may take a `:csrf_token_key` option
+          which is useful when using `:protect_from_forgery` with a custom
+          `:session_key`. If not given, it defaults to `"_csrf_token"`.
+
       Arbitrary keywords may also appear following the above valid keys, which
       is useful for passing custom connection information to the socket.
 
diff --git a/lib/phoenix/router.ex b/lib/phoenix/router.ex
index 7bb545834..91174dca4 100644
--- a/lib/phoenix/router.ex
+++ b/lib/phoenix/router.ex
@@ -100,16 +100,20 @@ defmodule Phoenix.Router do
       GET /pages/hey/there/world
       %{"page" => "y", "rest" => ["there" "world"]} = params
 
-  ## Helpers
+  ## Generating routes
 
-  Phoenix automatically generates a module `Helpers` inside your router
-  by default, which contains named helpers to help developers generate and keep
-  their routes up to date. Helpers can be disabled by passing `helpers: false`
-  to `use Phoenix.Router`.
+  For generating routes inside your application,  see the `Phoenix.VerifiedRoutes`
+  documentation for `~p` based route generation which is the preferred way to
+  generate route paths and URLs with compile-time verification.
 
-  See the `Phoenix.VerifiedRoutes` documentation for `~p` based route generation
-  which is the preferred way to generate route paths and URLs with compile-time
-  verification.
+  Phoenix also supports generating function helpers, which was the default
+  mechanism in Phoenix v1.6 and earlier. we will explore it next.
+
+  ### Helpers
+
+  Phoenix generates a module `Helpers` inside your router by default, which contains
+  named helpers to help developers generate and keep their routes up to date.
+  Helpers can be disabled by passing `helpers: false` to `use Phoenix.Router`.
 
   Helpers are automatically generated based on the controller name.
   For example, the route:
@@ -314,7 +318,7 @@ defmodule Phoenix.Router do
   and you can compose them as necessary on each scope you define.
   """
 
-  # TODO: Deprecate trailing_slash? and remove :as from the docs.
+  # TODO: Deprecate trailing_slash?
   alias Phoenix.Router.{Resource, Scope, Route, Helpers}
 
   @http_methods [:get, :post, :put, :patch, :delete, :options, :connect, :trace, :head]
@@ -332,6 +336,7 @@ defmodule Phoenix.Router do
     quote do
       Module.register_attribute(__MODULE__, :phoenix_routes, accumulate: true)
       @phoenix_forwards %{}
+      # TODO: Require :helpers to be explicit given
       @phoenix_helpers Keyword.get(unquote(opts), :helpers, true)
 
       import Phoenix.Router
@@ -673,8 +678,8 @@ defmodule Phoenix.Router do
 
   ## Options
 
-    * `:as` - configures the named helper exclusively. If false, does not generate
-      a helper.
+    * `:as` - configures the named helper. If false, does not generate
+      a helper. Has no effect when using verified routes exclusively
     * `:alias` - configure if the scope alias should be applied to the route.
       Defaults to true, disables scoping if false.
     * `:log` - the level to log the route dispatching under,
@@ -889,7 +894,8 @@ defmodule Phoenix.Router do
       and as the prefix for the parameter in nested resources. The default value
       is automatically derived from the controller name, i.e. `UserController` will
       have name `"user"`
-    * `:as` - configures the named helper exclusively
+    * `:as` - configures the named helper. If false, does not generate
+      a helper. Has no effect when using verified routes exclusively
     * `:singleton` - defines routes for a singleton resource that is looked up by
       the client without referencing an ID. Read below for more information
 
@@ -992,7 +998,8 @@ defmodule Phoenix.Router do
 
     * `:path` - a string containing the path scope.
     * `:as` - a string or atom containing the named helper scope. When set to
-      false, it resets the nested helper scopes.
+      false, it resets the nested helper scopes. Has no effect when using verified
+      routes exclusively
     * `:alias` - an alias (atom) containing the controller scope. When set to
       false, it resets all nested aliases.
     * `:host` - a string or list of strings containing the host scope, or prefix host scope,
diff --git a/lib/phoenix/router/console_formatter.ex b/lib/phoenix/router/console_formatter.ex
index 9950420c3..017219e1b 100644
--- a/lib/phoenix/router/console_formatter.ex
+++ b/lib/phoenix/router/console_formatter.ex
@@ -4,6 +4,11 @@ defmodule Phoenix.Router.ConsoleFormatter do
   @doc """
   Format the routes for printing.
   """
+
+  @socket_verb "WS"
+
+  @longpoll_verbs ["GET", "POST"]
+
   def format(router, endpoint \\ nil) do
     routes = Phoenix.Router.routes(router)
     column_widths = calculate_column_widths(router, routes, endpoint)
@@ -32,7 +37,7 @@ defmodule Phoenix.Router.ConsoleFormatter do
       {verb_len, path_len, route_name_len} = widths
 
       String.pad_leading(prefix, route_name_len) <> "  " <>
-      String.pad_trailing("WS", verb_len) <> "  " <>
+      String.pad_trailing(@socket_verb, verb_len) <> "  " <>
       String.pad_trailing(path <> "/websocket", path_len) <> "  " <>
       inspect(module) <>
       "\n"
@@ -45,7 +50,7 @@ defmodule Phoenix.Router.ConsoleFormatter do
   defp format_longpoll({path, module, opts}, router, widths) do
     if opts[:longpoll] != false do
       prefix = if router.__helpers__(), do: "longpoll", else: ""
-      for method <- ["GET", "POST"], into: "" do
+      for method <- @longpoll_verbs, into: "" do
         {verb_len, path_len, route_name_len} = widths
 
         String.pad_leading(prefix, route_name_len) <> "  " <>
@@ -73,11 +78,12 @@ defmodule Phoenix.Router.ConsoleFormatter do
         max(route_name_len, String.length(route_name))}
       end)
 
-    Enum.reduce(sockets, widths, fn {path, _mod, _opts}, acc ->
+    Enum.reduce(sockets, widths, fn {path, _mod, opts}, acc ->
       {verb_len, path_len, route_name_len} = acc
       prefix = if router.__helpers__(), do: "websocket", else: ""
+      verb_length = socket_verbs(opts) |> Enum.map(&String.length/1) |> Enum.max(&>=/2, fn -> 0 end)
 
-      {verb_len,
+      {max(verb_len, verb_length),
       max(path_len, String.length(path <> "/websocket")),
       max(route_name_len, String.length(prefix))}
     end)
@@ -106,4 +112,12 @@ defmodule Phoenix.Router.ConsoleFormatter do
   end
 
   defp verb_name(verb), do: verb |> to_string() |> String.upcase()
+
+  defp socket_verbs(socket_opts) do
+    if socket_opts[:longpoll] != false do
+      [@socket_verb | @longpoll_verbs]
+    else
+      [@socket_verb]
+    end
+  end
 end
diff --git a/lib/phoenix/socket/transport.ex b/lib/phoenix/socket/transport.ex
index 6b18835e5..24ed391ff 100644
--- a/lib/phoenix/socket/transport.ex
+++ b/lib/phoenix/socket/transport.ex
@@ -259,11 +259,14 @@ defmodule Phoenix.Socket.Transport do
     [connect_info: connect_info] ++ config
   end
 
+  # The original session_config is returned in addition to init value so we can
+  # access special config like :csrf_token_key downstream.
   defp init_session(session_config) when is_list(session_config) do
     key = Keyword.fetch!(session_config, :key)
     store = Plug.Session.Store.get(Keyword.fetch!(session_config, :store))
     init = store.init(Keyword.drop(session_config, [:store, :key]))
-    {key, store, init}
+    csrf_token_key = Keyword.get(session_config, :csrf_token_key, "_csrf_token")
+    {key, store, {csrf_token_key, init}}
   end
 
   defp init_session({_, _, _} = mfa) do
@@ -465,15 +468,15 @@ defmodule Phoenix.Socket.Transport do
     end
   end
 
-  defp connect_session(conn, endpoint, {key, store, store_config}) do
+  defp connect_session(conn, endpoint, {key, store, {csrf_token_key, init}}) do
     conn = Plug.Conn.fetch_cookies(conn)
 
     with csrf_token when is_binary(csrf_token) <- conn.params["_csrf_token"],
          cookie when is_binary(cookie) <- conn.cookies[key],
          conn = put_in(conn.secret_key_base, endpoint.config(:secret_key_base)),
-         {_, session} <- store.get(conn, cookie, store_config),
+         {_, session} <- store.get(conn, cookie, init),
          csrf_state when is_binary(csrf_state) <-
-           Plug.CSRFProtection.dump_state_from_session(session["_csrf_token"]),
+          Plug.CSRFProtection.dump_state_from_session(session[csrf_token_key]),
          true <- Plug.CSRFProtection.valid_state_and_csrf_token?(csrf_state, csrf_token) do
       session
     else
diff --git a/priv/templates/phx.gen.auth/settings_live.ex b/priv/templates/phx.gen.auth/settings_live.ex
index e59b7f67e..2b1a42f0c 100644
--- a/priv/templates/phx.gen.auth/settings_live.ex
+++ b/priv/templates/phx.gen.auth/settings_live.ex
@@ -39,7 +39,12 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web
       phx-submit="update_password"
       phx-trigger-action={@trigger_submit}
     >
-      <.input field={@password_form[:email]} type="hidden" value={@current_email} />
+      <.input
+        field={@password_form[:email]}
+        type="hidden"
+        id="hidden_<%= schema.singular %>_email"
+        value={@current_email}
+      />
       <.input field={@password_form[:password]} type="password" label="New password" required />
       <.input
         field={@password_form[:password_confirmation]}
diff --git a/priv/templates/phx.gen.live/core_components.ex b/priv/templates/phx.gen.live/core_components.ex
index 15edda02a..e485078e0 100644
--- a/priv/templates/phx.gen.live/core_components.ex
+++ b/priv/templates/phx.gen.live/core_components.ex
@@ -3,8 +3,10 @@ defmodule <%= @web_namespace %>.CoreComponents do
   Provides core UI components.
 
   The components in this module use Tailwind CSS, a utility-first CSS framework.
-  See the [Tailwind CSS documentation](https://tailwindcss.com) to learn how to
-  customize the generated components in this module.
+  You are welcome to change these components in any way according to the needs
+  of your application. See the [Tailwind CSS documentation](https://tailwindcss.com)
+  to learn how to customize the generated components in this module. Alternatively,
+  feel free to swap to another framework altogether.
 
   Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage.
   """
@@ -19,37 +21,28 @@ defmodule <%= @web_namespace %>.CoreComponents do
   ## Examples
 
       <.modal id="confirm-modal">
-        Are you sure?
-        <:confirm>OK</:confirm>
-        <:cancel>Cancel</:cancel>
+        This is a modal.
       </.modal>
 
-  JS commands may be passed to the `:on_cancel` and `on_confirm` attributes
-  for the caller to react to each button press, for example:
+  JS commands may be passed to the `:on_cancel` to configure
+  the closing/cancel event, for example:
 
-      <.modal id="confirm" on_confirm={JS.push("delete")} on_cancel={JS.navigate(~p"/posts")}>
-        Are you sure you?
-        <:confirm>OK</:confirm>
-        <:cancel>Cancel</:cancel>
+      <.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}>
+        This is another modal.
       </.modal>
+
   """
   attr :id, :string, required: true
   attr :show, :boolean, default: false
   attr :on_cancel, JS, default: %JS{}
-  attr :on_confirm, JS, default: %JS{}
-
   slot :inner_block, required: true
-  slot :title
-  slot :subtitle
-  slot :confirm
-  slot :cancel
 
   def modal(assigns) do
     ~H"""
     <div
       id={@id}
       phx-mounted={@show && show_modal(@id)}
-      phx-remove={hide_modal(@id)}
+      phx-remove={hide_modal(@on_cancel, @id)}
       class="relative z-50 hidden"
     >
       <div id={"#{@id}-bg"} class="fixed inset-0 bg-zinc-50/90 transition-opacity" aria-hidden="true" />
@@ -65,15 +58,14 @@ defmodule <%= @web_namespace %>.CoreComponents do
           <div class="w-full max-w-3xl p-4 sm:p-6 lg:py-8">
             <.focus_wrap
               id={"#{@id}-container"}
-              phx-mounted={@show && show_modal(@id)}
-              phx-window-keydown={hide_modal(@on_cancel, @id)}
+              phx-window-keydown={JS.exec("phx-remove", to: "##{@id}")}
               phx-key="escape"
-              phx-click-away={hide_modal(@on_cancel, @id)}
+              phx-click-away={JS.exec("phx-remove", to: "##{@id}")}
               class="hidden relative rounded-2xl bg-white p-14 shadow-lg shadow-zinc-700/10 ring-1 ring-zinc-700/10 transition"
             >
               <div class="absolute top-6 right-5">
                 <button
-                  phx-click={hide_modal(@on_cancel, @id)}
+                  phx-click={JS.exec("phx-remove", to: "##{@id}")}
                   type="button"
                   class="-m-3 flex-none p-3 opacity-20 hover:opacity-40"
                   aria-label=<%= if @gettext do %>{gettext("close")}<% else %>"close"<% end %>
@@ -82,37 +74,7 @@ defmodule <%= @web_namespace %>.CoreComponents do
                 </button>
               </div>
               <div id={"#{@id}-content"}>
-                <header :if={@title != []}>
-                  <h1 id={"#{@id}-title"} class="text-lg font-semibold leading-8 text-zinc-800">
-                    <%%= render_slot(@title) %>
-                  </h1>
-                  <p
-                    :if={@subtitle != []}
-                    id={"#{@id}-description"}
-                    class="mt-2 text-sm leading-6 text-zinc-600"
-                  >
-                    <%%= render_slot(@subtitle) %>
-                  </p>
-                </header>
                 <%%= render_slot(@inner_block) %>
-                <div :if={@confirm != [] or @cancel != []} class="ml-6 mb-4 flex items-center gap-5">
-                  <.button
-                    :for={confirm <- @confirm}
-                    id={"#{@id}-confirm"}
-                    phx-click={@on_confirm}
-                    phx-disable-with
-                    class="py-2 px-3"
-                  >
-                    <%%= render_slot(confirm) %>
-                  </.button>
-                  <.link
-                    :for={cancel <- @cancel}
-                    phx-click={hide_modal(@on_cancel, @id)}
-                    class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
-                  >
-                    <%%= render_slot(cancel) %>
-                  </.link>
-                </div>
               </div>
             </.focus_wrap>
           </div>
@@ -134,8 +96,6 @@ defmodule <%= @web_namespace %>.CoreComponents do
   attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
   attr :title, :string, default: nil
   attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
-  attr :autoshow, :boolean, default: true, doc: "whether to auto show the flash on mount"
-  attr :close, :boolean, default: true, doc: "whether the flash can be closed"
   attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
 
   slot :inner_block, doc: "the optional inner block that renders the flash message"
@@ -145,28 +105,22 @@ defmodule <%= @web_namespace %>.CoreComponents do
     <div
       :if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
       id={@id}
-      phx-mounted={@autoshow && show("##{@id}")}
       phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
       role="alert"
       class={[
-        "fixed hidden top-2 right-2 w-80 sm:w-96 z-50 rounded-lg p-3 shadow-md shadow-zinc-900/5 ring-1",
+        "fixed hidden top-2 right-2 w-80 sm:w-96 z-50 rounded-lg p-3 ring-1",
         @kind == :info && "bg-emerald-50 text-emerald-800 ring-emerald-500 fill-cyan-900",
-        @kind == :error && "bg-rose-50 p-3 text-rose-900 shadow-md ring-rose-500 fill-rose-900"
+        @kind == :error && "bg-rose-50 text-rose-900 shadow-md ring-rose-500 fill-rose-900"
       ]}
       {@rest}
     >
-      <p :if={@title} class="flex items-center gap-1.5 text-[0.8125rem] font-semibold leading-6">
+      <p :if={@title} class="flex items-center gap-1.5 text-sm font-semibold leading-6">
         <.icon :if={@kind == :info} name="hero-information-circle-mini" class="w-4 h-4" />
         <.icon :if={@kind == :error} name="hero-exclamation-circle-mini" class="w-4 h-4" />
         <%%= @title %>
       </p>
-      <p class="mt-2 text-[0.8125rem] leading-5"><%%= msg %></p>
-      <button
-        :if={@close}
-        type="button"
-        class="group absolute top-2 right-1 p-2"
-        aria-label=<%= if @gettext do %>{gettext("close")}<% else %>"close"<% end %>
-      >
+      <p class="mt-2 text-sm leading-5"><%%= msg %></p>
+      <button type="button" class="group absolute top-1 right-1 p-2" aria-label=<%= if @gettext do %>{gettext("close")}<% else %>"close"<% end %>>
         <.icon name="hero-x-mark-solid" class="w-5 h-5 opacity-40 group-hover:opacity-70" />
       </button>
     </div>
@@ -190,10 +144,9 @@ defmodule <%= @web_namespace %>.CoreComponents do
       id="disconnected"
       kind={:error}
       title="We can't find the internet"
-      close={false}
-      autoshow={false}
       phx-disconnected={show("#disconnected")}
       phx-connected={hide("#disconnected")}
+      hidden
     >
       Attempting to reconnect <.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" />
     </.flash>
@@ -323,7 +276,7 @@ defmodule <%= @web_namespace %>.CoreComponents do
           name={@name}
           value="true"
           checked={@checked}
-          class="rounded border-zinc-300 text-zinc-900 focus:ring-zinc-900"
+          class="rounded border-zinc-300 text-zinc-900 focus:ring-0"
           {@rest}
         />
         <%%= @label %>
@@ -340,7 +293,7 @@ defmodule <%= @web_namespace %>.CoreComponents do
       <select
         id={@id}
         name={@name}
-        class="mt-1 block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-zinc-500 focus:border-zinc-500 sm:text-sm"
+        class="mt-1 block w-full border border-gray-300 bg-white rounded-md shadow-sm focus:ring-0 focus:border-zinc-400 sm:text-sm"
         multiple={@multiple}
         {@rest}
       >
@@ -360,11 +313,11 @@ defmodule <%= @web_namespace %>.CoreComponents do
         id={@id || @name}
         name={@name}
         class={[
-          "mt-2 block min-h-[6rem] w-full rounded-lg border-zinc-300 py-[7px] px-[11px]",
-          "text-zinc-900 focus:border-zinc-400 focus:outline-none focus:ring-4 focus:ring-zinc-800/5 sm:text-sm sm:leading-6",
-          "phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400 phx-no-feedback:focus:ring-zinc-800/5",
-          "border-zinc-300 focus:border-zinc-400 focus:ring-zinc-800/5",
-          @errors != [] && "border-rose-400 focus:border-rose-400 focus:ring-rose-400/10"
+          "mt-2 block min-h-[6rem] w-full rounded-lg",
+          "text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6",
+          "phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400",
+          "border-zinc-300 focus:border-zinc-400",
+          @errors != [] && "border-rose-400 focus:border-rose-400"
         ]}
         {@rest}
       ><%%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
@@ -373,6 +326,7 @@ defmodule <%= @web_namespace %>.CoreComponents do
     """
   end
 
+  # All other inputs text, datetime-local, url, password, etc. are handled here...
   def input(assigns) do
     ~H"""
     <div phx-feedback-for={@name}>
@@ -383,11 +337,11 @@ defmodule <%= @web_namespace %>.CoreComponents do
         id={@id || @name}
         value={Phoenix.HTML.Form.normalize_value(@type, @value)}
         class={[
-          "mt-2 block w-full rounded-lg border-zinc-300 py-[7px] px-[11px]",
-          "text-zinc-900 focus:outline-none focus:ring-4 sm:text-sm sm:leading-6",
-          "phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400 phx-no-feedback:focus:ring-zinc-800/5",
-          "border-zinc-300 focus:border-zinc-400 focus:ring-zinc-800/5",
-          @errors != [] && "border-rose-400 focus:border-rose-400 focus:ring-rose-400/10"
+          "mt-2 block w-full rounded-lg border-zinc-300",
+          "text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6",
+          "phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400",
+          "border-zinc-300 focus:border-zinc-400",
+          @errors != [] && "border-rose-400 focus:border-rose-400"
         ]}
         {@rest}
       />
@@ -586,15 +540,13 @@ defmodule <%= @web_namespace %>.CoreComponents do
   You can customize the size and colors of the icons by setting
   width, height, and background color classes.
 
-  Icons are extracted from your `priv/hero_icons` directory and bundled
+  Icons are extracted from your `assets/vendor/heroicons` directory and bundled
   within your compiled app.css by the plugin in your `assets/tailwind.config.js`.
 
   ## Examples
 
-      <.icon name="hero-cake" />
-      <.icon name="hero-cake-solid" />
-      <.icon name="hero-cake-mini" />
-      <.icon name="hero-bolt" class="bg-blue-500 w-10 h-10" />
+      <.icon name="hero-x-mark-solid" />
+      <.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" />
   """
   attr :name, :string, required: true
   attr :class, :string, default: nil
diff --git a/priv/templates/phx.gen.live/index.html.heex b/priv/templates/phx.gen.live/index.html.heex
index a6daca49d..872db4d36 100644
--- a/priv/templates/phx.gen.live/index.html.heex
+++ b/priv/templates/phx.gen.live/index.html.heex
@@ -29,12 +29,7 @@
   </:action>
 </.table>
 
-<.modal
-  :if={@live_action in [:new, :edit]}
-  id="<%= schema.singular %>-modal"
-  show
-  on_cancel={JS.navigate(~p"<%= schema.route_prefix %>")}
->
+<.modal :if={@live_action in [:new, :edit]} id="<%= schema.singular %>-modal" show on_cancel={JS.patch(~p"<%= schema.route_prefix %>")}>
   <.live_component
     module={<%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.FormComponent}
     id={@<%= schema.singular %>.id || :new}


Test patch:
diff --git a/installer/test/phx_new_test.exs b/installer/test/phx_new_test.exs
index c5595b4f9..7ebaf733c 100644
--- a/installer/test/phx_new_test.exs
+++ b/installer/test/phx_new_test.exs
@@ -134,6 +134,8 @@ defmodule Mix.Tasks.Phx.NewTest do
       assert_file("phx_blog/lib/phx_blog_web/controllers/page_html/home.html.heex")
 
       # assets
+      assert_file("phx_blog/priv/static/images/logo.svg")
+
       assert_file("phx_blog/.gitignore", fn file ->
         assert file =~ "/priv/static/assets/"
         assert file =~ "phx_blog-*.tar"
@@ -148,11 +150,11 @@ defmodule Mix.Tasks.Phx.NewTest do
       # tailwind
       assert_file("phx_blog/assets/css/app.css")
       assert_file("phx_blog/assets/tailwind.config.js")
-      assert_file("phx_blog/priv/hero_icons/LICENSE.md")
-      assert_file("phx_blog/priv/hero_icons/UPGRADE.md")
-      assert_file("phx_blog/priv/hero_icons/optimized/24/outline/cake.svg")
-      assert_file("phx_blog/priv/hero_icons/optimized/24/solid/cake.svg")
-      assert_file("phx_blog/priv/hero_icons/optimized/20/solid/cake.svg")
+      assert_file("phx_blog/assets/vendor/heroicons/LICENSE.md")
+      assert_file("phx_blog/assets/vendor/heroicons/UPGRADE.md")
+      assert_file("phx_blog/assets/vendor/heroicons/optimized/24/outline/cake.svg")
+      assert_file("phx_blog/assets/vendor/heroicons/optimized/24/solid/cake.svg")
+      assert_file("phx_blog/assets/vendor/heroicons/optimized/20/solid/cake.svg")
 
       refute File.exists?("phx_blog/priv/static/assets/app.css")
       refute File.exists?("phx_blog/priv/static/assets/app.js")
@@ -319,11 +321,12 @@ defmodule Mix.Tasks.Phx.NewTest do
         assert file =~ ~r/\n$/
       end)
 
+      refute File.exists?("phx_blog/priv/static/images/logo.svg")
+
       assert_file("phx_blog/config/dev.exs", ~r/watchers: \[\]/)
 
       # No assets & No HTML
       refute_file("phx_blog/priv/static/assets/app.css")
-      refute_file("phx_blog/priv/static/favicon.ico")
       refute_file("phx_blog/priv/static/assets/app.js")
 
       # No Ecto
@@ -756,7 +759,7 @@ defmodule Mix.Tasks.Phx.NewTest do
   end
 
   test "invalid options" do
-    assert_raise Mix.Error, ~r/Invalid option: -d/, fn ->
+    assert_raise OptionParser.ParseError, fn ->
       Mix.Tasks.Phx.New.run(["valid", "-database", "mysql"])
     end
   end
diff --git a/installer/test/phx_new_umbrella_test.exs b/installer/test/phx_new_umbrella_test.exs
index 99bd4f160..2e86d82b9 100644
--- a/installer/test/phx_new_umbrella_test.exs
+++ b/installer/test/phx_new_umbrella_test.exs
@@ -343,7 +343,6 @@ defmodule Mix.Tasks.Phx.New.UmbrellaTest do
       # No assets & No HTML
       refute_file(web_path(@app, "priv/static/assets/app.js"))
       refute_file(web_path(@app, "priv/static/assets/app.css"))
-      refute_file(web_path(@app, "priv/static/favicon.ico"))
 
       # No Ecto
       config = ~r/config :phx_umb, PhxUmb.Repo,/
@@ -720,7 +719,7 @@ defmodule Mix.Tasks.Phx.New.UmbrellaTest do
   end
 
   test "invalid options" do
-    assert_raise Mix.Error, ~r/Invalid option: -d/, fn ->
+    assert_raise OptionParser.ParseError, fn ->
       Mix.Tasks.Phx.New.run(["valid5", "-database", "mysql", "--umbrella"])
     end
   end
diff --git a/test/phoenix/socket/transport_test.exs b/test/phoenix/socket/transport_test.exs
index 9d7c6107f..2a3e3df9f 100644
--- a/test/phoenix/socket/transport_test.exs
+++ b/test/phoenix/socket/transport_test.exs
@@ -23,13 +23,20 @@ defmodule Phoenix.Socket.TransportTest do
       signing_salt: "change_me"
     ]
 
-    def session_config, do: @session_config
+    def session_config(overrides \\ []), do: Keyword.merge(@session_config, overrides)
 
     plug Plug.Session, @session_config
     plug :fetch_session
-    plug Plug.CSRFProtection
+    plug :put_csrf
     plug :put_session
 
+    defp put_csrf(conn, _opts) do
+      conn = Plug.Conn.fetch_query_params(conn)
+      session_key = Map.get(conn.query_params, "session_key", "_csrf_token")
+      opts = Plug.CSRFProtection.init(session_key: session_key)
+      Plug.CSRFProtection.call(conn, opts)
+    end
+
     defp put_session(conn, _) do
       conn
       |> put_session(:from_session, "123")
@@ -288,5 +295,40 @@ defmodule Phoenix.Socket.TransportTest do
                |> fetch_query_params()
                |> Transport.connect_info(Endpoint, connect_info)
     end
+
+    test "loads the session with custom :csrf_token_key" do
+      conn = conn(:get, "https://foo.com?session_key=_custom_csrf_token") |> Endpoint.call([])
+      csrf_token = conn.resp_body
+      session_cookie = conn.cookies["_hello_key"]
+
+      connect_info = load_connect_info(
+        session: {
+          Endpoint,
+          :session_config,
+          [[csrf_token_key: "_custom_csrf_token"]]
+        }
+      )
+
+      assert %{session: %{"from_session" => "123"}} =
+              conn(:get, "https://foo.com/", _csrf_token: csrf_token)
+              |> put_req_cookie("_hello_key", session_cookie)
+              |> fetch_query_params()
+              |> Transport.connect_info(Endpoint, connect_info)
+
+      connect_info = load_connect_info(
+        session: {
+          Endpoint,
+          :session_config,
+          [[csrf_token_key: "bad_key"]]
+        }
+      )
+
+      assert %{session: nil} =
+              conn(:get, "https://foo.com/", _csrf_token: csrf_token)
+              |> put_req_cookie("_hello_key", session_cookie)
+              |> fetch_query_params()
+              |> Transport.connect_info(Endpoint, connect_info)
+    end
+
   end
 end