diff --git a/db/migrate/20261030180126_add_app_theme_to_coplan_users.co_plan.rb b/db/migrate/20261030180126_add_app_theme_to_coplan_users.co_plan.rb new file mode 100644 index 00000000..46714fad --- /dev/null +++ b/db/migrate/20261030180126_add_app_theme_to_coplan_users.co_plan.rb @@ -0,0 +1,7 @@ +# This migration comes from co_plan (originally 20261030000000) +class AddAppThemeToCoplanUsers < ActiveRecord::Migration[8.1] + # "default" follows the host's config.default_theme (docs/THEMES.md). + def change + add_column :coplan_users, :app_theme, :string, null: false, default: "default" + end +end diff --git a/db/schema.rb b/db/schema.rb index a954c7a9..ba07dbc4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_10_19_185311) do +ActiveRecord::Schema[8.1].define(version: 2026_10_30_180126) do create_table "active_admin_comments", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "author_id" t.string "author_type" @@ -515,6 +515,7 @@ create_table "coplan_users", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.boolean "admin", default: false, null: false + t.string "app_theme", default: "default", null: false t.string "avatar_url" t.datetime "created_at", null: false t.string "email" diff --git a/docs/HOST_APP_GUIDE.md b/docs/HOST_APP_GUIDE.md index 9d8e7543..5a2de11b 100644 --- a/docs/HOST_APP_GUIDE.md +++ b/docs/HOST_APP_GUIDE.md @@ -6,8 +6,9 @@ CoPlan is a Rails engine that manages collaborative planning documents. It owns ## User settings and host integrations -CoPlan owns the settings shown on its Settings page. Light/dark mode and -push-to-talk preferences live in `coplan_users.theme_preference` and `coplan_users.voice_hotkey`. +CoPlan owns the settings shown on its Settings page. The app theme, light/dark +mode and push-to-talk preferences live in `coplan_users.app_theme`, +`coplan_users.theme_preference` and `coplan_users.voice_hotkey`. Notification preferences have their own column; browser subscriptions and API tokens have engine-owned tables. User `metadata` is available for host directory data and is not a settings API. @@ -217,6 +218,7 @@ The engine manages a `coplan_users` table with these columns: | `name` | String | Display name | | `admin` | Boolean | Admin flag | | `metadata` | JSON | Host directory data; not CoPlan settings | +| `app_theme` | String | Engine-owned app theme: `default` (follow `config.default_theme`) or a `CoPlan::Themes` key | | `theme_preference` | String | Engine-owned light/dark mode: system, light, or dark | | `voice_hotkey` | String | Engine-owned push-to-talk preference | @@ -315,7 +317,8 @@ CoPlan.configure do |config| AnalyticsEvent.create!(name: event, payload: payload) } - # App theme (optional). One of CoPlan::Themes.keys; see docs/THEMES.md. + # App theme for everyone who hasn't picked one in Settings (optional). + # One of CoPlan::Themes.keys: "classic" or "neutral". See docs/THEMES.md. config.default_theme = "classic" # default end ``` diff --git a/docs/THEMES.md b/docs/THEMES.md index f77f5ed0..5a09b871 100644 --- a/docs/THEMES.md +++ b/docs/THEMES.md @@ -9,7 +9,7 @@ defines both. | Term | Means | In code | | --- | --- | --- | | Mode | Light, dark or system | `data-theme`, `theme_preference`, `theme_controller.js`. These names predate app themes and stay. | -| Theme | The app's look: Classic, … | `data-app-theme`, `CoPlan::Themes`, `config.default_theme` | +| Theme | The app's look: Classic or Neutral | `data-app-theme`, `CoPlan::Themes`, `config.default_theme` | | Deck theme | A slide look: `coplan`, `graphite` | the `theme` attribute on a presentation region. Always say "deck theme". | If the mode attribute is ever renamed, move `data-theme` to `data-mode` and @@ -20,13 +20,35 @@ sets no variables. Every rule that a theme can change reads a variable with Classic's value as the fallback, `var(--hook, )`. A theme sets only the hooks it changes. +Neutral is for reading: warm grays, the system font, no glass or page +backdrop, and the document set flat on the page. Its headings keep the shared +size scale. It lives in `themes/neutral.css`. + ## How a theme is applied -The layout renders ``. The value is the host's -`config.default_theme` (see [HOST_APP_GUIDE.md](HOST_APP_GUIDE.md)). An -unknown name stops boot in development and test. In other environments it -logs a warning and falls back to `classic`. Light and dark stay on -`data-theme`. +The layout renders `` +(`ApplicationHelper#coplan_app_theme`). Light and dark stay on `data-theme`. + +The value is the viewer's own pick, if they made one. Each person chooses +under Settings → Appearance → Theme, stored in `coplan_users.app_theme`. Its +values are `default` or a theme key. `default`, the column default, follows +the host's `config.default_theme` (see [HOST_APP_GUIDE.md](HOST_APP_GUIDE.md)), +so a host can change the look for everyone who hasn't chosen. A stored key +that is no longer a theme falls back to that default. An unknown +`config.default_theme` stops boot in development and test. In other +environments it logs a warning and falls back to `classic`. + +Picking a theme applies at once, without a reload (`app_theme_controller.js`): +it sets `data-app-theme`, dispatches `coplan:theme-changed` on `window`, and +saves with `PATCH /settings/app_theme` in the background. If the save fails, +the old choice comes back. Code that reads theme values in JavaScript, such +as the Mermaid palette, should redraw on `coplan:theme-changed`. + +The Appearance button in the top bar, beside notifications, offers the same +choices from any page (`shared/_appearance_menu.html.erb`): the mode, and +Classic or Neutral with the theme that's showing checked. It has no Default +option; Settings keeps that. The menu and Settings use the same controllers +and keep each other's radios in step (`coplan:app-theme`). The themes are listed in `CoPlan::Themes::ALL` (`engine/app/models/coplan/themes.rb`). @@ -36,12 +58,16 @@ The themes are listed in `CoPlan::Themes::ALL` 1. Add an entry to `CoPlan::Themes::ALL`. 2. Add `engine/app/assets/stylesheets/coplan/themes/.css` and import it last in `coplan/application.css`. -3. Set tokens and hooks under `:root[data-app-theme=""]`. For dark mode, - repeat them under `:root[data-app-theme=""][data-theme="dark"]` and - inside `@media (prefers-color-scheme: dark)` under - `:root[data-app-theme=""]:not([data-theme])`. -4. Set custom properties only. A hook can also be set on a component, for - example `:root[data-app-theme=""] .plan-document { --color-surface: … }`. +3. Set tokens and hooks under `:root[data-app-theme=""]`. Give each + color its light and dark value with `light-dark(, )`, which + follows the mode, including inside borders and shadows. Give a shadow the + same layers in both modes and use `transparent` for a layer one mode + doesn't have. The diagram palette is the exception (below). +4. Set custom properties only, so a theme changes how CoPlan looks, never + what it does. A hook can also be set on a component, for example + `:root[data-app-theme=""] .plan-document { --color-surface: … }`. + Anything a theme would solve with a rule, such as an opening H1 that + repeats the plan title, belongs to every theme, in shared CSS or markup. A hook set on `:root` resolves any `var()` it uses at the root. To follow a token that changes further down the page, set the hook on that component. @@ -50,7 +76,12 @@ Decks are fixed artifacts. `.deck` sets every document hook to `initial`, which brings back the built-in value, so themes never restyle slides. Code panels on slides pin their own colors in `.deck-slide .deck-content pre`. `spec/lib/deck_theme_isolation_spec.rb` checks that every variable a theme -file sets is reset there or allowlisted with a reason. +file sets is reset there or allowlisted with a reason. Decks also set +`--font-sans` and `--font-mono` back to the built-in stacks, +`--font-builtin-sans` and `--font-builtin-mono`, so diagram labels and code +inside a slide keep the deck's fonts. Themes don't set those two. Mermaid +reads its label font and its palette from the diagram's container, not the +page. ## Tokens @@ -60,6 +91,12 @@ Every token in `foundation.css` `:root` can be overridden, for example `--shadow-*`. Keep blue for interaction and orange for attention ([DESIGN.md](DESIGN.md)). +Shadows mix heights. `--shadow-glass` is on cards and pills on the page and +also on menus and dialogs. `--shadow-pop` is on buttons and hover lifts. +`--shadow-lg` is on thread windows and other popovers. To keep page cards +flat while menus float, set a quiet `--shadow-glass` on `:root` and set it +again on the floating panels. Neutral shows how. + ## Hooks | Area | Hooks | @@ -71,14 +108,15 @@ Every token in `foundation.css` `:root` can be overridden, for example | Panels | `--panel-bg`, `--panel-edge`, `--panel-shadow`, `--panel-row-bg`, `--document-support-radius` | | Document card | `--document-bg`, `--document-border`, `--document-radius`, `--document-shadow`, `--document-padding`, `--document-padding-narrow` (phones) | | Plan title | `--plan-title-size`, `--plan-title-size-narrow`, `--plan-title-weight`, `--plan-title-leading`, `--plan-title-tracking`, `--plan-title-wrap` | -| Reading type | `--reading-measure`, `--reading-leading`, `--reading-paragraph-gap`, `--reading-heading-weight`, `--reading-heading-tracking`, `--reading-heading-wrap`, `--reading-h1-size`, `--reading-h1-leading`, and for h2 and h3: `-size`, `-leading`, `-space-above`, `-space-below` | +| Reading type | `--reading-measure`, `--reading-leading`, `--reading-paragraph-gap`, `--reading-heading-weight`, `--reading-heading-tracking`, `--reading-heading-wrap`, `--reading-h1-size`, `--reading-h1-leading`, and for h2 and h3: `-size`, `-leading`, `-space-above`, `-space-below`; `--reading-h4-weight` (defaults to the heading weight) | | Emphasis | `--strong-weight` | -| Code blocks | `--code-block-border`, `--code-block-radius`, `--code-block-padding`, `--code-block-font-size`, `--code-block-leading`, `--code-block-scrollbar` | +| Code blocks | `--code-block-text`, `--code-block-border`, `--code-block-radius`, `--code-block-padding`, `--code-block-font-size`, `--code-block-leading`, `--code-block-scrollbar` | | Code label | `--code-label-height`, `--code-label-margin`, `--code-label-padding`, `--code-label-bg`, `--code-label-dots`, `--code-label-border`, `--code-label-radius`, `--code-label-font`, `--code-label-leading`, `--code-label-tracking`, `--code-label-align` | | Inline code | `--code-chip-padding`, `--code-chip-radius`, `--code-chip-size` | | Diagrams | `--diagram-bg`, `--diagram-border`, `--diagram-radius` | | Tables | `--table-radius`, `--table-numerals` | -| Other | `--blockquote-rule`, `--image-outline` | +| Images | `--image-outline`, `--image-radius` | +| Other | `--blockquote-rule` | Reading type applies to the document body in the reader and the editor only. Comments, previews and decks keep their own type. @@ -86,13 +124,17 @@ only. Comments, previews and decks keep their own type. ## Diagram palette A theme can give Mermaid diagrams its own colors. Set all of these as hex -values (Mermaid's color math can't read `oklch()`), per scheme: +values (Mermaid's color math can't read `oklch()`). Mermaid reads them with +`getComputedStyle`, which returns `light-dark()` unresolved, so set the dark +values in a block under `:root[data-app-theme=""][data-theme="dark"]` and +one inside `@media (prefers-color-scheme: dark)` under +`:root[data-app-theme=""]:not([data-theme])`: `--diagram-bg`, `--diagram-node`, `--diagram-node-border`, `--diagram-text`, `--diagram-line`, `--diagram-cluster`, `--diagram-cluster-border`, `--diagram-actor-line`, `--diagram-alt`, `--diagram-note`, `--diagram-note-border`, `--diagram-note-text`. Each diagram reads the palette from its own container. If any of them is -missing, documents keep Mermaid's built-in themes. Decks always do, because -`.deck` resets `--diagram-bg`. An expanded deck diagram keeps the slide's -background, so its built-in colors stay readable in either mode. +missing there, the diagram uses Mermaid's built-in themes. `.deck` sets them +all to `initial`, so slides always do. An expanded deck diagram keeps the +slide's background, so its built-in colors stay readable in either mode. diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index c944daf3..1e7153b9 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -29,3 +29,4 @@ @import url("./review.css"); @import url("./agent-access.css"); @import url("./not-found.css"); +@import url("./themes/neutral.css"); diff --git a/engine/app/assets/stylesheets/coplan/deck.css b/engine/app/assets/stylesheets/coplan/deck.css index f68dc5c1..fe805d9e 100644 --- a/engine/app/assets/stylesheets/coplan/deck.css +++ b/engine/app/assets/stylesheets/coplan/deck.css @@ -32,10 +32,23 @@ --diagram-bg: initial; --diagram-border: initial; --diagram-radius: initial; + /* With no diagram palette, Mermaid uses its built-in themes. */ + --diagram-node: initial; + --diagram-node-border: initial; + --diagram-text: initial; + --diagram-line: initial; + --diagram-cluster: initial; + --diagram-cluster-border: initial; + --diagram-actor-line: initial; + --diagram-alt: initial; + --diagram-note: initial; + --diagram-note-border: initial; + --diagram-note-text: initial; --blockquote-rule: initial; --table-radius: initial; --table-numerals: initial; --image-outline: initial; + --image-radius: initial; } .deck-slide { diff --git a/engine/app/assets/stylesheets/coplan/markdown.css b/engine/app/assets/stylesheets/coplan/markdown.css index ef637d41..619730fc 100644 --- a/engine/app/assets/stylesheets/coplan/markdown.css +++ b/engine/app/assets/stylesheets/coplan/markdown.css @@ -177,6 +177,10 @@ margin-bottom: var(--reading-h3-space-below, var(--space-md)); } +:is(.plan-reader, .document-editor__body) .markdown-rendered:not(.deck-content) h4 { + font-weight: var(--reading-h4-weight, var(--reading-heading-weight, 700)); +} + :is(.plan-reader, .document-editor__body) .markdown-rendered:not(.deck-content) > :is(h1, h2, h3):first-child { margin-top: 0; } @@ -192,6 +196,7 @@ padding: var(--code-block-padding, var(--space-md)); overflow-x: auto; margin-bottom: var(--space-md); + color: var(--code-block-text, inherit); font-family: var(--font-mono); font-size: var(--code-block-font-size, var(--text-sm)); line-height: var(--code-block-leading, 1.5); @@ -609,7 +614,7 @@ .markdown-rendered img { max-width: 100%; - border-radius: var(--radius); + border-radius: var(--image-radius, var(--radius)); outline: var(--image-outline, none); outline-offset: -1px; } @@ -618,7 +623,7 @@ its reading layout. Only the corner affordance appears on hover/focus. */ .document-editor__image { position: relative; display: inline-block; max-width: 100%; vertical-align: top; line-height: 0; } .document-editor__image img { display: block; height: auto; } -.document-editor__image-handle { --image-handle-opacity: 0; position: absolute; right: 0; bottom: 0; display: grid; place-items: end; width: 36px; height: 36px; padding: 2px; border: 0; border-radius: 0 0 var(--radius) 0; background: transparent; color: rgb(51 65 85 / .65); cursor: nwse-resize; touch-action: none; transition: color .18s ease; } +.document-editor__image-handle { --image-handle-opacity: 0; position: absolute; right: 0; bottom: 0; display: grid; place-items: end; width: 36px; height: 36px; padding: 2px; border: 0; border-radius: 0 0 var(--image-radius, var(--radius)) 0; background: transparent; color: rgb(51 65 85 / .65); cursor: nwse-resize; touch-action: none; transition: color .18s ease; } /* Clear glass picks up the image beneath it, including in dark mode. Keep the triangular paint separate from the larger rectangular grab area. Fade the painted layers, never their parent: parent opacity below 1 @@ -669,6 +674,12 @@ margin-bottom: var(--space-md); } +/* Raised text keeps the line's height, so a footnote marker doesn't push + its line apart from the others. */ +.markdown-rendered :is(sup, sub) { + line-height: 0; +} + .markdown-rendered .footnote-ref a, .markdown-rendered sup a[data-footnote-ref] { text-decoration: none; diff --git a/engine/app/assets/stylesheets/coplan/navigation.css b/engine/app/assets/stylesheets/coplan/navigation.css index d73b6065..16a7280c 100644 --- a/engine/app/assets/stylesheets/coplan/navigation.css +++ b/engine/app/assets/stylesheets/coplan/navigation.css @@ -87,6 +87,97 @@ background: var(--color-interaction-hover-bg); } +/* Appearance: mode and theme, one click away (shared/_appearance_menu). */ +.site-nav__appearance { + position: relative; + flex-shrink: 0; +} + +.site-nav__appearance-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 7px; + border: none; + background: none; + border-radius: var(--radius); + color: var(--color-text-muted); + cursor: pointer; + transition: color 0.15s, background 0.15s; +} + +.site-nav__appearance-btn:hover, +.site-nav__appearance-btn:has(+ .appearance-menu:popover-open) { + color: var(--color-primary-hover); + background: var(--color-interaction-hover-bg); +} + +.appearance-menu { + width: 17rem; +} + +.appearance-menu__group { + margin: 0; + padding: 0; + border: 0; + min-width: 0; +} + +.appearance-menu .segmented--fill { + display: flex; + margin: 2px; +} + +.segmented--fill .segmented__option { + flex: 1; +} + +.segmented--fill .segmented__btn { + justify-content: center; + width: 100%; + padding: 6px var(--space-sm); +} + +.appearance-menu__theme { + cursor: pointer; +} + +.appearance-menu__theme input[type="radio"] { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.appearance-menu__theme-name { + flex: 1; +} + +/* The theme that's showing: blue marks the selection (docs/DESIGN.md). */ +.appearance-menu__check { + visibility: hidden; +} + +.appearance-menu__theme:has(input:checked) { + color: var(--color-primary); + font-weight: 600; +} + +.appearance-menu__theme:has(input:checked) .appearance-menu__check { + visibility: visible; + color: var(--color-primary); +} + +.appearance-menu__theme:has(input:focus-visible) { + outline: 2px solid var(--color-primary); + outline-offset: -2px; +} + +.appearance-menu__error { + margin: var(--space-xs) var(--space-sm); + font-size: var(--text-sm); + color: var(--color-danger); +} + /* When the bell has folded into the menu (phone widths), the ☰ carries a small unread dot so a waiting notification is still visible at a glance. coplan--inbox-badge toggles .site-nav--has-unread from the live count; diff --git a/engine/app/assets/stylesheets/coplan/plan-header.css b/engine/app/assets/stylesheets/coplan/plan-header.css index b01b6556..60d3c1e2 100644 --- a/engine/app/assets/stylesheets/coplan/plan-header.css +++ b/engine/app/assets/stylesheets/coplan/plan-header.css @@ -118,8 +118,9 @@ overflow-wrap: anywhere; } -/* The plan title (theme hooks; docs/THEMES.md). */ -.page-header .page-header__title { +/* The plan title (theme hooks; docs/THEMES.md). The editable title in + edit mode reads the same hooks, so Edit doesn't resize it. */ +.page-header :is(.page-header__title, .inline-editor__title) { font-size: var(--plan-title-size, var(--text-2xl)); font-weight: var(--plan-title-weight, 700); line-height: var(--plan-title-leading, 1.6); diff --git a/engine/app/assets/stylesheets/coplan/responsive.css b/engine/app/assets/stylesheets/coplan/responsive.css index ee1074ed..4b8cce29 100644 --- a/engine/app/assets/stylesheets/coplan/responsive.css +++ b/engine/app/assets/stylesheets/coplan/responsive.css @@ -54,6 +54,7 @@ tablet with the bell still on the bar is just as tappable. */ @media (pointer: coarse) { .site-nav__menu-btn, + .site-nav__appearance-btn, .site-nav__bell { min-width: 44px; min-height: 44px; @@ -119,10 +120,11 @@ display: block; } - /* Finger-sized tap targets: the ☰ and the folded search icon each fill a - 44px hit area, and menu rows get real height + spacing so they're not a - pixel-hunt to tap. */ - .site-nav__menu-btn { + /* Finger-sized tap targets: the ☰, Appearance and the folded search icon + each fill a 44px hit area, and menu rows get real height + spacing so + they're not a pixel-hunt to tap. */ + .site-nav__menu-btn, + .site-nav__appearance-btn { min-width: 44px; min-height: 44px; } @@ -163,7 +165,7 @@ overflow-wrap: anywhere; } - .page-header .page-header__title { + .page-header :is(.page-header__title, .inline-editor__title) { font-size: var(--plan-title-size-narrow, var(--text-xl)); } @@ -224,3 +226,17 @@ font-size: 0.75rem; } } + +/* ---- Narrowest phones ---- + The Appearance menu hangs from its button, left of ☰, so at 320px a + 17rem menu would run off the left edge. Mode segments drop their icons + and the menu narrows to fit. */ +@media (max-width: 400px) { + .appearance-menu { + width: 15rem; + } + + .appearance-menu .segmented__btn svg { + display: none; + } +} diff --git a/engine/app/assets/stylesheets/coplan/review.css b/engine/app/assets/stylesheets/coplan/review.css index 879b5041..2878728a 100644 --- a/engine/app/assets/stylesheets/coplan/review.css +++ b/engine/app/assets/stylesheets/coplan/review.css @@ -491,7 +491,7 @@ body:has(.review-tray[data-completed="true"]:not([data-reconsidering="true"]):no .document-editor__footnote { color: var(--color-primary); font-size: 0.7em; white-space: nowrap; } -.document-editor__footnote button { border: 0; background: transparent; color: inherit; padding: 0 2px; font: inherit; cursor: pointer; border-radius: 3px; } +.document-editor__footnote button { border: 0; background: transparent; color: inherit; padding: 0 2px; font: inherit; line-height: 1; cursor: pointer; border-radius: 3px; } .document-editor__footnote button:hover { background: var(--color-interaction-hover-bg); } .document-editor__footnote button:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; } .document-editor__block-source { display: block; width: 100%; min-height: 320px; resize: vertical; border: 0; padding: 18px; background: var(--color-surface); color: var(--color-text); font: 14px/1.7 var(--font-mono, monospace); box-sizing: border-box; } diff --git a/engine/app/assets/stylesheets/coplan/search-and-shortcuts.css b/engine/app/assets/stylesheets/coplan/search-and-shortcuts.css index 4187400a..55a8c8fd 100644 --- a/engine/app/assets/stylesheets/coplan/search-and-shortcuts.css +++ b/engine/app/assets/stylesheets/coplan/search-and-shortcuts.css @@ -28,6 +28,7 @@ .site-nav__search:focus-visible, .site-nav__bell:focus-visible, +.site-nav__appearance-btn:focus-visible, .site-nav__menu-btn:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 3px; diff --git a/engine/app/assets/stylesheets/coplan/settings.css b/engine/app/assets/stylesheets/coplan/settings.css index f3298044..c7b6f76d 100644 --- a/engine/app/assets/stylesheets/coplan/settings.css +++ b/engine/app/assets/stylesheets/coplan/settings.css @@ -99,6 +99,18 @@ gap: var(--space-md); } +/* A card holding several related rows (Appearance: theme and mode). */ +.settings-group__title { + margin: 0 0 var(--space-md); + font-size: var(--text-base); +} + +.settings-group .settings-row + .settings-row { + margin-top: var(--space-md); + padding-top: var(--space-md); + border-top: 1px solid var(--color-border); +} + .settings-row__label { font-weight: 600; color: var(--color-text); @@ -186,7 +198,7 @@ margin-top: var(--space-xs); } -/* Segmented control (theme switcher, push-to-talk key) */ +/* Segmented control (theme, mode, push-to-talk key) */ .segmented { display: inline-flex; /* The control is the row's point; the explanation next to it is what @@ -236,6 +248,11 @@ color: var(--color-text); } +.segmented__option:has(input:focus-visible) .segmented__btn { + outline: 2px solid var(--color-primary); + outline-offset: -2px; +} + .segmented__option:has(input:checked) .segmented__btn { background: var(--color-primary-light); color: var(--color-primary); diff --git a/engine/app/assets/stylesheets/coplan/themes/neutral.css b/engine/app/assets/stylesheets/coplan/themes/neutral.css new file mode 100644 index 00000000..867fc2ed --- /dev/null +++ b/engine/app/assets/stylesheets/coplan/themes/neutral.css @@ -0,0 +1,251 @@ +/* Neutral — a quiet reading theme (docs/THEMES.md). + + Warm grays at very low chroma (hue ~90), the system font, solid surfaces + with no blur, and a document that sits directly on the page instead of a + card. Blue still means interaction and orange still means attention. + + Custom properties only, so the theme changes how CoPlan looks, never what + it does. Each color gives its light and dark value in light-dark(), which + follows the mode's color-scheme. A var() in a custom property resolves + where it is declared, so hooks that follow the document's own surface are + set on the document, not on :root. */ + +:root[data-app-theme="neutral"] { + /* Colors. --color-surface is the floating surface (popovers, menus, the + composer), white so it lifts off the page. Inside the document it + becomes the page (below); --color-surface-floating keeps the original. */ + --color-bg: light-dark(oklch(0.993 0.0015 106), oklch(0.212 0.003 90)); + --color-bg-muted: light-dark(oklch(0.97 0.003 90), oklch(0.235 0.004 90)); + --color-surface: light-dark(oklch(1 0 0), oklch(0.255 0.004 90)); + --color-surface-muted: light-dark(oklch(0.98 0.003 90), oklch(0.28 0.004 90)); + --color-surface-floating: var(--color-surface); + --color-surface-floating-muted: var(--color-surface-muted); + /* Muted panels set in the document (back matter, table headers). */ + --color-page-muted: light-dark(oklch(0.975 0.003 90), oklch(0.235 0.004 90)); + --color-input-bg: var(--color-surface); + --color-text: light-dark(oklch(0.287 0.005 95), oklch(0.893 0.004 95)); + --color-text-muted: light-dark(oklch(0.53 0.008 90), oklch(0.75 0.006 90)); + --color-border: light-dark(oklch(0.915 0.005 90), oklch(0.3 0.005 90)); + --color-border-strong: light-dark(oklch(0.84 0.007 90), oklch(0.38 0.006 90)); + --color-tag-bg: light-dark(oklch(0.97 0.003 90), oklch(0.235 0.004 90)); + --color-tag-text: light-dark(oklch(0.35 0.005 95), oklch(0.86 0.005 95)); + --color-tag-hover-bg: light-dark(oklch(0.915 0.005 90), oklch(0.3 0.005 90)); + --color-tag-hover-text: light-dark(oklch(0.17 0.004 95), oklch(0.955 0.003 95)); + + /* Code: a borderless tinted block with a small corner language label. + Syntax colors are soft and warm, so code reads as part of the page. */ + --color-code-bg: light-dark(oklch(0.955 0.005 90), oklch(0.255 0.004 90)); + --code-block-bg: light-dark(oklch(0.969 0.004 98), oklch(0.262 0.005 90)); + --code-block-text: light-dark(oklch(0.28 0.006 90), oklch(0.88 0.005 90)); + --code-header-text: light-dark(oklch(0.5 0.008 90), oklch(0.75 0.006 90)); + --code-comment: light-dark(oklch(0.5 0.01 90), oklch(0.72 0.008 90)); + --code-keyword: light-dark(oklch(0.5 0.13 35), oklch(0.8 0.1 35)); + --code-entity: light-dark(oklch(0.45 0.08 250), oklch(0.79 0.07 235)); + --code-constant: light-dark(oklch(0.52 0.11 65), oklch(0.82 0.09 75)); + --code-string: light-dark(oklch(0.48 0.09 140), oklch(0.8 0.08 135)); + --code-tag: light-dark(oklch(0.5 0.12 20), oklch(0.8 0.09 20)); + --code-addition-fg: light-dark(oklch(0.46 0.1 140), oklch(0.8 0.09 140)); + --code-addition-bg: light-dark(oklch(0.95 0.035 140), oklch(0.8 0.09 140 / 0.12)); + --code-deletion-fg: light-dark(oklch(0.5 0.14 30), oklch(0.8 0.1 30)); + --code-deletion-bg: light-dark(oklch(0.95 0.025 30), oklch(0.7 0.12 30 / 0.14)); + + /* Typography */ + --font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --font-mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, monospace; + + /* Solid surfaces: page-level panels (nav, rows) use the page color, + floating panels the floating surface. Every panel has a 1px border, so + light adds a drop and dark adds nothing: a drop can't be seen on a dark + page, and a ring would double the border. Three heights: cards and + pills on the page barely lift (--shadow-glass), buttons and hover lifts + a little (--shadow-pop), and menus, dialogs and thread windows float + (--shadow-lg, and --shadow-glass on the floating panels below). */ + --shadow-lg: + 0 1px 2px light-dark(oklch(0.3 0.01 90 / 0.06), transparent), + 0 14px 32px -10px light-dark(oklch(0.3 0.01 90 / 0.18), transparent); + --shadow-pop: + 0 1px 2px light-dark(oklch(0.3 0.01 90 / 0.06), transparent), + 0 3px 8px -2px light-dark(oklch(0.3 0.01 90 / 0.1), transparent); + --shadow-glass: 0 1px 2px light-dark(oklch(0.3 0.01 90 / 0.05), transparent); + --glass-bg: var(--color-bg); + --glass-bg-strong: var(--color-surface); + --glass-edge: light-dark(oklch(0.9 0.005 90), oklch(0.33 0.005 90)); + --surface-translucent: var(--color-surface); + --card-edge: var(--color-border); + + /* Page, glass and windows */ + --page-backdrop: none; + --glass-blur: none; + --window-blur: none; + --overlay-blur: none; + --window-bg: var(--color-surface); + --window-edge: var(--glass-edge); + --window-shadow: var(--shadow-pop); + --composer-input-bg: var(--color-input-bg); + --overlay-tint: oklch(0.15 0.004 90 / 0.55); + --dialog-backdrop: var(--color-overlay); + --panel-bg: var(--color-surface-muted); + --panel-shadow: none; + --panel-row-bg: transparent; + --document-support-radius: 6px; + + /* The document has no card: its text sits on the page. Phones keep room + at the start for the update dot's hit area (left: -23px). */ + --document-bg: var(--color-bg); + --document-border: 0; + --document-radius: 0; + --document-shadow: none; + --document-padding: var(--space-sm) 0; + --document-padding-narrow: var(--space-sm) 0 var(--space-sm) var(--space-lg); + + /* Plan title, set like the document's headings. It stays a step above + the document's H1 (the shared 1.5rem) on phones too. */ + --plan-title-size: 1.75rem; + --plan-title-size-narrow: 1.625rem; + --plan-title-weight: 500; + --plan-title-leading: 1.2; + --plan-title-tracking: -0.01em; + --plan-title-wrap: balance; + + /* Reading type. Heading sizes come from the shared scale, so each level + is a step below the one above it. A ch is the width of "0", wider than + an average letter, so 68ch holds about 85 characters a line. H4 is body + size, so weight is what marks it. */ + --reading-measure: 68ch; + --reading-leading: 1.6; + --reading-paragraph-gap: 1.12em; + --reading-heading-weight: 500; + --reading-h4-weight: 600; + --reading-heading-tracking: -0.01em; + --reading-heading-wrap: balance; + --reading-h1-leading: 1.25; + --reading-h2-leading: 1.3; + --reading-h2-space-above: 2.5rem; + --reading-h2-space-below: 0.75rem; + --reading-h3-leading: 1.35; + --reading-h3-space-above: 2rem; + --reading-h3-space-below: 0.75rem; + --strong-weight: 600; + + /* Code blocks and their corner label */ + --code-block-border: 0; + --code-block-radius: 6px; + --code-block-padding: 1.125rem 1.25rem; + --code-block-font-size: 0.84375rem; + --code-block-leading: 1.65; + --code-block-scrollbar: thin; + --code-label-height: 1rem; + --code-label-margin: -0.25rem 0 0.375rem; + --code-label-padding: 0; + --code-label-bg: transparent; + --code-label-dots: none; + --code-label-border: 0; + --code-label-radius: 0; + --code-label-font: var(--font-sans); + --code-label-leading: 1rem; + --code-label-tracking: 0.01em; + --code-label-align: right; + --code-chip-padding: 0.12em 0.36em; + --code-chip-radius: 4px; + --code-chip-size: 0.86em; + + /* Diagrams, tables and the rest */ + --diagram-border: 0; + --diagram-radius: 6px; + --table-radius: 6px; + --table-numerals: tabular-nums; + --image-radius: 6px; + --blockquote-rule: var(--color-border-strong); + --image-outline: 1px solid light-dark(rgb(0 0 0 / 0.1), rgb(255 255 255 / 0.1)); + + /* Mermaid palette, as hex (Mermaid's color math can't read oklch()). + --diagram-bg is also the diagram's background, the same tint as a code + block in both modes. Mermaid reads these with getComputedStyle, which + returns light-dark() unresolved, so dark mode sets them in its own + blocks below. */ + --diagram-bg: #f7f6f3; + --diagram-node: #f0ece4; + --diagram-node-border: #bab0a0; + --diagram-text: #1c1a17; + --diagram-line: #81796d; + --diagram-cluster: #fbfaf7; + --diagram-cluster-border: #d4d1ca; + --diagram-actor-line: #8f8c84; + --diagram-alt: #f2e5db; + --diagram-note: #f7efd1; + --diagram-note-border: #cfbc8a; + --diagram-note-text: #483b24; +} + +/* The Mermaid palette in dark mode, forced */ +:root[data-app-theme="neutral"][data-theme="dark"] { + --diagram-bg: #252422; + --diagram-node: #2c2a26; + --diagram-node-border: #5c584f; + --diagram-text: #ecebe8; + --diagram-line: #95928a; + --diagram-cluster: #282725; + --diagram-cluster-border: #3c3a37; + --diagram-actor-line: #706e69; + --diagram-alt: #352f2a; + --diagram-note: #312b1a; + --diagram-note-border: #695d3c; + --diagram-note-text: #ebe5cf; +} + +/* The Mermaid palette in dark mode, following the system */ +@media (prefers-color-scheme: dark) { + :root[data-app-theme="neutral"]:not([data-theme]) { + --diagram-bg: #252422; + --diagram-node: #2c2a26; + --diagram-node-border: #5c584f; + --diagram-text: #ecebe8; + --diagram-line: #95928a; + --diagram-cluster: #282725; + --diagram-cluster-border: #3c3a37; + --diagram-actor-line: #706e69; + --diagram-alt: #352f2a; + --diagram-note: #312b1a; + --diagram-note-border: #695d3c; + --diagram-note-text: #ebe5cf; + } +} + +/* Inside the document, --color-surface is the page, so tables, back + matter and the editor's code header blend in. */ +:root[data-app-theme="neutral"] .plan-document { + --color-surface: var(--color-bg); + --color-surface-muted: var(--color-page-muted); + --color-interaction-hover-bg: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg)); + --color-interaction-active-bg: color-mix(in srgb, var(--color-primary) 12%, var(--color-bg)); + --code-header-bg: var(--color-surface-muted); +} + +/* Anything that floats over the document gets the floating surface back. */ +:root[data-app-theme="neutral"] .plan-document :is([popover], .comment-popover, .comment-window) { + --color-surface: var(--color-surface-floating); + --color-surface-muted: var(--color-surface-floating-muted); + --color-interaction-hover-bg: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface-floating)); + --color-interaction-active-bg: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface-floating)); +} + +/* Floating panels that read --shadow-glass get the floating drop; cards and + pills on the page keep the quiet one. The selection's Comment button + floats over text, so it lifts like a button. */ +:root[data-app-theme="neutral"] :is(.menu, .review-dialog, .plan-visibility-panel, .review-assignment, .content-nav[popover]) { + --shadow-glass: var(--shadow-lg); +} + +:root[data-app-theme="neutral"] .comment-popover__action { + --shadow-glass: var(--shadow-pop); +} + +/* Back matter is a flat muted panel on the document's own surface. */ +:root[data-app-theme="neutral"] .document-support { + --panel-edge: var(--color-border); + --panel-bg: var(--color-surface-muted); +} + +:root[data-app-theme="neutral"] #new-plan-modal { + --overlay-tint: oklch(0.15 0.004 90 / 0.58); +} diff --git a/engine/app/controllers/coplan/settings/settings_controller.rb b/engine/app/controllers/coplan/settings/settings_controller.rb index 14d1049f..90dd3972 100644 --- a/engine/app/controllers/coplan/settings/settings_controller.rb +++ b/engine/app/controllers/coplan/settings/settings_controller.rb @@ -17,6 +17,18 @@ def update_theme head :ok end + # Saves the app theme (docs/THEMES.md). + def update_app_theme + app_theme = params[:app_theme] + if CoPlan::User::APP_THEMES.include?(app_theme) + current_user.app_theme = app_theme + current_user.save! + head :ok + else + head :unprocessable_content + end + end + def update_voice_hotkey hotkey = params[:voice_hotkey] if CoPlan::User::VOICE_HOTKEYS.include?(hotkey) diff --git a/engine/app/helpers/coplan/application_helper.rb b/engine/app/helpers/coplan/application_helper.rb index 577339b6..d65c3067 100644 --- a/engine/app/helpers/coplan/application_helper.rb +++ b/engine/app/helpers/coplan/application_helper.rb @@ -3,9 +3,17 @@ module ApplicationHelper include CoPlan::KeyboardShortcutsHelper include MarkdownHelper - # The app theme for this page, as (docs/THEMES.md). + # The app theme for this page, as (docs/THEMES.md): + # the viewer's pick from Settings, else the host's default. def coplan_app_theme - CoPlan::Themes.default.key + choice = current_user.app_theme if signed_in? + CoPlan::Themes.resolve(choice).key + end + + # The viewer's pick as Settings shows it: a theme key, or "default". A + # stored theme that CoPlan no longer ships counts as "default". + def coplan_app_theme_choice + (CoPlan::Themes.find(current_user.app_theme)&.key if signed_in?) || CoPlan::User::DEFAULT_APP_THEME end def coplan_favicon_tag diff --git a/engine/app/javascript/controllers/coplan/app_theme_controller.js b/engine/app/javascript/controllers/coplan/app_theme_controller.js new file mode 100644 index 00000000..e3f9a1e7 --- /dev/null +++ b/engine/app/javascript/controllers/coplan/app_theme_controller.js @@ -0,0 +1,132 @@ +import { Controller } from "@hotwired/stimulus" + +/* + * coplan--app-theme + * + * Switches the app theme (Classic, Neutral, …) on as + * soon as it's picked, then saves it in the background (docs/THEMES.md). + * "default" follows the site's default, which the defaultValue holds. + * coplan:theme-changed lets Mermaid redraw with the new palette. + * + * Like the push-to-talk row, a save that doesn't land puts the old choice + * back and says so, so the page never shows a theme the server won't + * render on the next visit. + * + * Settings and the bar's Appearance menu each have one. coplan:app-theme + * tells the others about a pick or a save, so their radios agree. The menu + * has no Default option, so it checks the theme that's showing. It's an + * event of its own, not coplan:theme-changed plus a look at , because + * holds only the resolved theme: it can't tell Default from the theme + * Default resolves to, or a pick from a save. + */ +// One save queue for the page, shared by every control: Settings and the +// menu are separate controllers, and a pick in one can follow a pick in the +// other before its save lands. +let saving = null + +export default class extends Controller { + static targets = ["error"] + // saved: the stored choice, "default" or a theme key. The menu's checked + // radio can't say which, so the page renders it. + static values = { url: String, default: String, saved: String } + + connect() { + this.saved = this.savedValue + this.follow = ({ detail }) => { + if (detail.source === this) return + this._check(detail.choice) + if (detail.saved) this.saved = detail.choice + } + window.addEventListener("coplan:app-theme", this.follow) + + // A page Turbo restores from its cache can carry an older pick; the + // theme on is current. + const checked = this.element.querySelector("input[type='radio']:checked") + const showing = document.documentElement.dataset.appTheme + if (checked && this._resolve(checked.value) !== showing) this._check(showing) + } + + disconnect() { + window.removeEventListener("coplan:app-theme", this.follow) + } + + // Saves go out one at a time, in the order they were picked in any + // control, so saved always matches the server. Only the latest pick's + // outcome touches the page, so quick clicks can't revert a newer choice. + select(event) { + const choice = event.target.value + this._apply(choice) + this._setError("") + this._announce(choice, false) + + const save = saving = (saving || Promise.resolve()) + .then(() => this._save(choice)) + .then(ok => { + if (ok) { + this.saved = choice + this._announce(choice, true) + } + return ok + }) + save.then(ok => { + if (save !== saving) return + saving = null + if (ok) return + + if (this.saved) { + this._check(this.saved) + this._apply(this.saved) + this._announce(this.saved, false) + } + this._setError("Couldn't save that — try again.") + }) + } + + async _save(choice) { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content + try { + const response = await fetch(this.urlValue, { + method: "PATCH", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-CSRF-Token": csrfToken + }, + body: `app_theme=${encodeURIComponent(choice)}` + }) + return response.ok + } catch { + // Offline or the request never landed — same outcome as a refusal. + return false + } + } + + // Checks the radio for a choice, or the theme it shows when this control + // has no radio for it (the menu has no Default). + _check(choice) { + const radios = [...this.element.querySelectorAll("input[type='radio']")] + const radio = radios.find(r => r.value === choice) || radios.find(r => r.value === this._resolve(choice)) + if (radio) radio.checked = true + } + + _resolve(choice) { + return choice === "default" ? this.defaultValue : choice + } + + _announce(choice, saved) { + window.dispatchEvent(new CustomEvent("coplan:app-theme", { detail: { choice, saved, source: this } })) + } + + _apply(choice) { + const theme = this._resolve(choice) + if (document.documentElement.dataset.appTheme === theme) return + + document.documentElement.dataset.appTheme = theme + window.dispatchEvent(new CustomEvent("coplan:theme-changed")) + } + + _setError(message) { + if (!this.hasErrorTarget) return + this.errorTarget.textContent = message + this.errorTarget.hidden = message === "" + } +} diff --git a/engine/app/javascript/controllers/coplan/mermaid_controller.js b/engine/app/javascript/controllers/coplan/mermaid_controller.js index 4c6e103a..796b3272 100644 --- a/engine/app/javascript/controllers/coplan/mermaid_controller.js +++ b/engine/app/javascript/controllers/coplan/mermaid_controller.js @@ -428,7 +428,7 @@ const DIAGRAM_PALETTE_PROPERTIES = { } // The theme's palette where the diagram sits, for the current scheme, or -// null when it isn't complete there. Decks reset --diagram-bg, so diagrams +// null when it isn't complete there. Decks reset the palette, so diagrams // in slides always get null and keep Mermaid's built-in themes. function diagramPalette(container) { const style = getComputedStyle(container) diff --git a/engine/app/javascript/controllers/coplan/theme_controller.js b/engine/app/javascript/controllers/coplan/theme_controller.js index 26c6c3a8..23fed67c 100644 --- a/engine/app/javascript/controllers/coplan/theme_controller.js +++ b/engine/app/javascript/controllers/coplan/theme_controller.js @@ -1,10 +1,27 @@ import { Controller } from "@hotwired/stimulus" // Switches light/dark mode (data-theme), not the app theme (docs/THEMES.md). -// The "theme" names predate app themes and are kept for hosts. +// The "theme" names predate app themes and are kept for hosts. Settings and +// the bar's Appearance menu each have one; both follow , so a pick in +// one shows in the other. export default class extends Controller { static values = { url: String } + connect() { + this.reflect = () => { + const mode = document.documentElement.getAttribute("data-theme") || "system" + const radio = this.element.querySelector(`input[type='radio'][value='${mode}']`) + if (radio) radio.checked = true + } + window.addEventListener("coplan:theme-changed", this.reflect) + // A page Turbo restores from its cache can carry an older pick. + this.reflect() + } + + disconnect() { + window.removeEventListener("coplan:theme-changed", this.reflect) + } + select(event) { const theme = event.target.value if (theme === "system") { diff --git a/engine/app/models/coplan/themes.rb b/engine/app/models/coplan/themes.rb index a135b880..15b67704 100644 --- a/engine/app/models/coplan/themes.rb +++ b/engine/app/models/coplan/themes.rb @@ -9,7 +9,8 @@ module Themes CLASSIC = "classic" ALL = [ - Theme.new(key: CLASSIC, name: "Classic", description: "Lexend, glass panels and the document card.") + Theme.new(key: CLASSIC, name: "Classic", description: "Lexend, glass panels and the document card."), + Theme.new(key: "neutral", name: "Neutral", description: "Warm grays, the system font and a flat page for reading.") ].freeze def self.keys = ALL.map(&:key) @@ -32,5 +33,10 @@ def self.check_default!(env: Rails.env, logger: Rails.logger) raise ArgumentError, message if env.development? || env.test? logger.warn("#{message} Falling back to #{CLASSIC}.") end + + # The theme a viewer's stored choice shows: the theme it names, else the + # default. "default" isn't a theme key, so it falls through, and so does + # a theme a later CoPlan release removed. + def self.resolve(choice) = find(choice) || default end end diff --git a/engine/app/models/coplan/user.rb b/engine/app/models/coplan/user.rb index 042aba8e..fa272f33 100644 --- a/engine/app/models/coplan/user.rb +++ b/engine/app/models/coplan/user.rb @@ -4,6 +4,12 @@ class User < ApplicationRecord # predate app themes and are persisted, so they stay. THEME_PREFERENCES = %w[system light dark].freeze + # The app theme picked in Settings (docs/THEMES.md). "default" follows + # the host's config.default_theme, so a host can change it for everyone + # who hasn't chosen. + DEFAULT_APP_THEME = "default".freeze + APP_THEMES = ([ DEFAULT_APP_THEME ] + Themes.keys).freeze + # Which key holds the microphone open for push-to-talk commenting. # A short list rather than a free capture: each one carries its own # feel in the browser (a bare modifier needs a hold delay, because @@ -35,6 +41,10 @@ class User < ApplicationRecord validates :external_id, presence: true, uniqueness: true # Settings belong to the engine; metadata is replaceable host/directory data. validates :theme_preference, inclusion: { in: THEME_PREFERENCES } + # Only a new choice is checked: a later CoPlan release can remove a + # theme, and the sign-in sync must still save users who picked it. Their + # pages fall back to the default (Themes.resolve). + validates :app_theme, inclusion: { in: APP_THEMES }, if: :app_theme_changed? validates :voice_hotkey, inclusion: { in: VOICE_HOTKEYS } validates :name, presence: true validates :email, uniqueness: true, allow_nil: true diff --git a/engine/app/views/coplan/settings/settings/_appearance.html.erb b/engine/app/views/coplan/settings/settings/_appearance.html.erb new file mode 100644 index 00000000..0c7286eb --- /dev/null +++ b/engine/app/views/coplan/settings/settings/_appearance.html.erb @@ -0,0 +1,61 @@ +<%# Appearance: the app theme (Classic, Neutral, …) and light/dark mode. + Both apply as soon as they're picked; see docs/THEMES.md. %> +<% site_default = CoPlan::Themes.default %> +<% chosen = coplan_app_theme_choice %> +
+

Appearance

+ +
+
+ Theme + +
+
+ + <% CoPlan::Themes::ALL.each do |theme| %> + + <% end %> +
+
+ +
+ Mode +
+ + + +
+
+
diff --git a/engine/app/views/coplan/settings/settings/index.html.erb b/engine/app/views/coplan/settings/settings/index.html.erb index 134de459..b16f1d40 100644 --- a/engine/app/views/coplan/settings/settings/index.html.erb +++ b/engine/app/views/coplan/settings/settings/index.html.erb @@ -2,34 +2,7 @@

Settings

-
-
- Theme -
- - - -
-
-
+<%= render "appearance" %> <%= render "voice" %> diff --git a/engine/app/views/coplan/shared/_appearance_menu.html.erb b/engine/app/views/coplan/shared/_appearance_menu.html.erb new file mode 100644 index 00000000..e23d6d30 --- /dev/null +++ b/engine/app/views/coplan/shared/_appearance_menu.html.erb @@ -0,0 +1,51 @@ +<%# The bar's appearance control: light/dark mode and the app theme, the same + choices as Settings → Appearance, one click from any page. A pick applies + at once and saves in the background (docs/THEMES.md); the popover stays + open so you can compare. Settings keeps "Default" for following the + site's theme; here the theme that's showing is the one checked. %> + + + + diff --git a/engine/app/views/layouts/coplan/application.html.erb b/engine/app/views/layouts/coplan/application.html.erb index e7964582..db0fed2c 100644 --- a/engine/app/views/layouts/coplan/application.html.erb +++ b/engine/app/views/layouts/coplan/application.html.erb @@ -47,10 +47,10 @@ bar reads the same on every surface. %> <%= yield :nav_title %>