From 900c5a430b6389b40f837076a09c10e002d80f44 Mon Sep 17 00:00:00 2001 From: Salman Mohammed Date: Fri, 30 Oct 2026 14:22:03 -0400 Subject: [PATCH 01/15] Add the Neutral theme and a per-user theme picker Neutral is the reading look from the warm-neutral reading view, ported into theme hooks: warm grays, the system font, no glass or page backdrop, the document flat on the page, quieter code blocks and its own Mermaid palette, in light and dark. It also hides an opening H1 that repeats the plan title; render_content_regions(title:) marks those documents, and Classic ignores the mark. Each person picks a theme under Settings > Appearance > Theme, next to the existing light/dark Mode row. coplan_users.app_theme stores "default" (follow the host's config.default_theme) or a theme key. The pick applies without a reload, saves with PATCH /settings/app_theme, and reverts if the save fails. A stored theme the host later removes falls back to the default and doesn't block saving the user. Co-Authored-By: Claude Opus 5.5 --- ...6_add_app_theme_to_coplan_users.co_plan.rb | 7 + db/schema.rb | 3 +- docs/HOST_APP_GUIDE.md | 9 +- docs/THEMES.md | 45 ++- .../assets/stylesheets/coplan/application.css | 1 + .../assets/stylesheets/coplan/markdown.css | 1 + .../assets/stylesheets/coplan/settings.css | 14 +- .../stylesheets/coplan/themes/neutral.css | 312 ++++++++++++++++++ .../coplan/settings/settings_controller.rb | 12 + .../app/helpers/coplan/application_helper.rb | 12 +- engine/app/helpers/coplan/markdown_helper.rb | 19 +- .../app/helpers/coplan/slideshows_helper.rb | 13 +- .../coplan/app_theme_controller.js | 81 +++++ engine/app/models/coplan/themes.rb | 3 +- engine/app/models/coplan/user.rb | 10 + .../views/coplan/plans/_content_body.html.erb | 4 +- .../settings/settings/_appearance.html.erb | 60 ++++ .../coplan/settings/settings/index.html.erb | 29 +- engine/config/routes.rb | 1 + ...030000000_add_app_theme_to_coplan_users.rb | 6 + spec/helpers/application_helper_spec.rb | 38 +++ spec/helpers/markdown_helper_spec.rb | 22 ++ spec/helpers/slideshows_helper_spec.rb | 16 + spec/models/themes_spec.rb | 6 + spec/models/user_spec.rb | 20 ++ spec/requests/settings_spec.rb | 43 +++ spec/system/app_theme_spec.rb | 182 ++++++++++ spec/system/changed_sections_ux_spec.rb | 5 +- 28 files changed, 920 insertions(+), 54 deletions(-) create mode 100644 db/migrate/20261030180126_add_app_theme_to_coplan_users.co_plan.rb create mode 100644 engine/app/assets/stylesheets/coplan/themes/neutral.css create mode 100644 engine/app/javascript/controllers/coplan/app_theme_controller.js create mode 100644 engine/app/views/coplan/settings/settings/_appearance.html.erb create mode 100644 engine/db/migrate/20261030000000_add_app_theme_to_coplan_users.rb create mode 100644 spec/system/app_theme_spec.rb 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..06269dff 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,30 @@ 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. It also hides an opening H1 +that repeats the plan title, since the page header already shows it (see +below). 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 themes are listed in `CoPlan::Themes::ALL` (`engine/app/models/coplan/themes.rb`). @@ -50,7 +67,19 @@ 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 +labels use the `--font-sans` of the diagram's container, not the page's. + +## Repeated titles + +Many plans open with an H1 that repeats the plan title. +`render_content_regions(content, title:)` marks such a document with +`markdown-rendered--repeats-title` (`MarkdownHelper#repeats_title?`). Classic +ignores the class. Neutral hides the H1 visually but keeps it in the page, and +shows it again while it holds a visible comment highlight. ## Tokens @@ -73,7 +102,7 @@ Every token in `foundation.css` `:root` can be overridden, for example | 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` | | 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` | 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/markdown.css b/engine/app/assets/stylesheets/coplan/markdown.css index ef637d41..a60053f6 100644 --- a/engine/app/assets/stylesheets/coplan/markdown.css +++ b/engine/app/assets/stylesheets/coplan/markdown.css @@ -192,6 +192,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); diff --git a/engine/app/assets/stylesheets/coplan/settings.css b/engine/app/assets/stylesheets/coplan/settings.css index f3298044..92abc83b 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 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..2ead97df --- /dev/null +++ b/engine/app/assets/stylesheets/coplan/themes/neutral.css @@ -0,0 +1,312 @@ +/* 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. 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: oklch(0.993 0.0015 106); + --color-bg-muted: oklch(0.97 0.003 90); + --color-surface: oklch(1 0 0); + --color-surface-muted: oklch(0.98 0.003 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: oklch(0.975 0.003 90); + --color-input-bg: var(--color-surface); + --color-text: oklch(0.287 0.005 95); + --color-text-muted: oklch(0.53 0.008 90); + --color-border: oklch(0.915 0.005 90); + --color-border-strong: oklch(0.84 0.007 90); + --color-tag-bg: oklch(0.97 0.003 90); + --color-tag-text: oklch(0.35 0.005 95); + --color-tag-hover-bg: oklch(0.915 0.005 90); + --color-tag-hover-text: oklch(0.17 0.004 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: oklch(0.955 0.005 90); + --code-block-bg: oklch(0.969 0.004 98); + --code-block-text: oklch(0.28 0.006 90); + --code-header-text: oklch(0.5 0.008 90); + --code-comment: oklch(0.5 0.01 90); + --code-keyword: oklch(0.5 0.13 35); + --code-entity: oklch(0.45 0.08 250); + --code-constant: oklch(0.52 0.11 65); + --code-string: oklch(0.48 0.09 140); + --code-tag: oklch(0.5 0.12 20); + --code-addition-fg: oklch(0.46 0.1 140); + --code-addition-bg: oklch(0.95 0.035 140); + --code-deletion-fg: oklch(0.5 0.14 30); + --code-deletion-bg: oklch(0.95 0.03 30); + + /* 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. */ + --shadow-lg: 0 1px 2px oklch(0.3 0.01 90 / 0.06), 0 14px 32px -10px oklch(0.3 0.01 90 / 0.18); + --shadow-pop: 0 1px 2px oklch(0.3 0.01 90 / 0.06), 0 14px 32px -10px oklch(0.3 0.01 90 / 0.18); + --shadow-glass: 0 1px 2px oklch(0.3 0.01 90 / 0.06), 0 14px 32px -10px oklch(0.3 0.01 90 / 0.18); + --glass-bg: var(--color-bg); + --glass-bg-strong: var(--color-surface); + --glass-edge: oklch(0.9 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 */ + --plan-title-size: 1.75rem; + --plan-title-weight: 500; + --plan-title-leading: 1.2; + --plan-title-tracking: -0.01em; + --plan-title-wrap: balance; + + /* Reading type */ + --reading-measure: 80ch; + --reading-leading: 1.6; + --reading-paragraph-gap: 1.12em; + --reading-heading-weight: 500; + --reading-heading-tracking: -0.01em; + --reading-heading-wrap: balance; + --reading-h1-size: 1.75rem; + --reading-h1-leading: 1.25; + --reading-h2-size: 1.375rem; + --reading-h2-leading: 1.3; + --reading-h2-space-above: 2.5rem; + --reading-h2-space-below: 0.75rem; + --reading-h3-size: 1.125rem; + --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; + --blockquote-rule: var(--color-border-strong); + --image-outline: 1px solid rgb(0 0 0 / 0.1); + + /* Mermaid palette, as hex (Mermaid's color math can't read oklch()). + --diagram-bg is also the diagram's background. */ + --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: #bcb7ad; + --diagram-alt: #f2e5db; + --diagram-note: #f7efd1; + --diagram-note-border: #cfbc8a; + --diagram-note-text: #483b24; +} + +/* Dark mode, forced */ +:root[data-app-theme="neutral"][data-theme="dark"] { + --color-bg: oklch(0.212 0.003 90); + --color-bg-muted: oklch(0.235 0.004 90); + --color-surface: oklch(0.255 0.004 90); + --color-surface-muted: oklch(0.28 0.004 90); + --color-page-muted: oklch(0.235 0.004 90); + --color-text: oklch(0.893 0.004 95); + --color-text-muted: oklch(0.68 0.006 90); + --color-border: oklch(0.3 0.005 90); + --color-border-strong: oklch(0.38 0.006 90); + --color-tag-bg: oklch(0.235 0.004 90); + --color-tag-text: oklch(0.86 0.005 95); + --color-tag-hover-bg: oklch(0.3 0.005 90); + --color-tag-hover-text: oklch(0.955 0.003 95); + + /* Code is fully raised in dark; syntax keeps the light hues, lifted. */ + --color-code-bg: oklch(0.255 0.004 90); + --code-block-bg: oklch(0.262 0.005 90); + --code-block-text: oklch(0.88 0.005 90); + --code-header-text: oklch(0.68 0.006 90); + --code-comment: oklch(0.68 0.008 90); + --code-keyword: oklch(0.76 0.1 35); + --code-entity: oklch(0.79 0.07 235); + --code-constant: oklch(0.82 0.09 75); + --code-string: oklch(0.8 0.08 135); + --code-tag: oklch(0.76 0.09 20); + --code-addition-fg: oklch(0.8 0.09 140); + --code-addition-bg: oklch(0.8 0.09 140 / 0.12); + --code-deletion-fg: oklch(0.76 0.1 30); + --code-deletion-bg: oklch(0.7 0.12 30 / 0.14); + + --shadow-lg: 0 0 0 1px rgb(0 0 0 / 0.3), 0 14px 32px -10px rgb(0 0 0 / 0.6); + --shadow-pop: 0 0 0 1px rgb(0 0 0 / 0.3), 0 14px 32px -10px rgb(0 0 0 / 0.6); + --shadow-glass: 0 0 0 1px rgb(0 0 0 / 0.3), 0 14px 32px -10px rgb(0 0 0 / 0.6); + --glass-edge: oklch(0.33 0.005 90); + --image-outline: 1px solid rgb(255 255 255 / 0.1); + + --diagram-bg: #1f1f1d; + --diagram-node: #2c2a26; + --diagram-node-border: #5c584f; + --diagram-text: #ecebe8; + --diagram-line: #95928a; + --diagram-cluster: #22211f; + --diagram-cluster-border: #3c3a37; + --diagram-actor-line: #4f4d48; + --diagram-alt: #352f2a; + --diagram-note: #312b1a; + --diagram-note-border: #695d3c; + --diagram-note-text: #ebe5cf; +} + +/* Dark mode, following the system (same values as above) */ +@media (prefers-color-scheme: dark) { + :root[data-app-theme="neutral"]:not([data-theme]) { + --color-bg: oklch(0.212 0.003 90); + --color-bg-muted: oklch(0.235 0.004 90); + --color-surface: oklch(0.255 0.004 90); + --color-surface-muted: oklch(0.28 0.004 90); + --color-page-muted: oklch(0.235 0.004 90); + --color-text: oklch(0.893 0.004 95); + --color-text-muted: oklch(0.68 0.006 90); + --color-border: oklch(0.3 0.005 90); + --color-border-strong: oklch(0.38 0.006 90); + --color-tag-bg: oklch(0.235 0.004 90); + --color-tag-text: oklch(0.86 0.005 95); + --color-tag-hover-bg: oklch(0.3 0.005 90); + --color-tag-hover-text: oklch(0.955 0.003 95); + + --color-code-bg: oklch(0.255 0.004 90); + --code-block-bg: oklch(0.262 0.005 90); + --code-block-text: oklch(0.88 0.005 90); + --code-header-text: oklch(0.68 0.006 90); + --code-comment: oklch(0.68 0.008 90); + --code-keyword: oklch(0.76 0.1 35); + --code-entity: oklch(0.79 0.07 235); + --code-constant: oklch(0.82 0.09 75); + --code-string: oklch(0.8 0.08 135); + --code-tag: oklch(0.76 0.09 20); + --code-addition-fg: oklch(0.8 0.09 140); + --code-addition-bg: oklch(0.8 0.09 140 / 0.12); + --code-deletion-fg: oklch(0.76 0.1 30); + --code-deletion-bg: oklch(0.7 0.12 30 / 0.14); + + --shadow-lg: 0 0 0 1px rgb(0 0 0 / 0.3), 0 14px 32px -10px rgb(0 0 0 / 0.6); + --shadow-pop: 0 0 0 1px rgb(0 0 0 / 0.3), 0 14px 32px -10px rgb(0 0 0 / 0.6); + --shadow-glass: 0 0 0 1px rgb(0 0 0 / 0.3), 0 14px 32px -10px rgb(0 0 0 / 0.6); + --glass-edge: oklch(0.33 0.005 90); + --image-outline: 1px solid rgb(255 255 255 / 0.1); + + --diagram-bg: #1f1f1d; + --diagram-node: #2c2a26; + --diagram-node-border: #5c584f; + --diagram-text: #ecebe8; + --diagram-line: #95928a; + --diagram-cluster: #22211f; + --diagram-cluster-border: #3c3a37; + --diagram-actor-line: #4f4d48; + --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)); +} + +/* 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); +} + +/* The plan title is already the page heading. When the document opens with + an H1 that repeats it exactly (MarkdownHelper#repeats_title?), take that + H1 out of view but keep it for screen readers and the outline's first + entry. A comment highlight inside it brings it back, so the comment stays + visible (resolved ones only while resolved threads are shown). */ +:root[data-app-theme="neutral"] .plan-reader .markdown-rendered--repeats-title > h1:first-child:is(:not(:has(mark)), .plan-layout--hide-resolved h1:not(:has(mark.anchor-highlight--open))) { + position: absolute; + width: 1px; + height: 1px; + margin: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +:root[data-app-theme="neutral"] .plan-reader .markdown-rendered--repeats-title > h1:first-child:is(:not(:has(mark)), .plan-layout--hide-resolved h1:not(:has(mark.anchor-highlight--open))) + * { + margin-top: 0; +} 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..dae87103 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 = coplan_app_theme_choice + choice == CoPlan::User::DEFAULT_APP_THEME ? CoPlan::Themes.default.key : choice + end + + # The viewer's pick as Settings shows it: a theme key, or "default". A + # stored theme the host has since retired 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/helpers/coplan/markdown_helper.rb b/engine/app/helpers/coplan/markdown_helper.rb index 13fb057a..ccbd6330 100644 --- a/engine/app/helpers/coplan/markdown_helper.rb +++ b/engine/app/helpers/coplan/markdown_helper.rb @@ -61,7 +61,10 @@ module MarkdownHelper # expandable_images: frame each image with the same full-window expander # diagrams and tables use. Follows data_tables by default, so decks opt # out of both together. - def render_markdown(content, interactive: true, footnote_prefix: nil, footnotes: :inline, line_offset: 0, data_tables: true, expandable_images: data_tables, source_comments: false, retain_sourcepos: false) + # + # repeats_title: the content opens with the plan title as its H1 (see + # repeats_title?). Marks the wrapper so the reader can hide that H1. + def render_markdown(content, interactive: true, footnote_prefix: nil, footnotes: :inline, line_offset: 0, data_tables: true, expandable_images: data_tables, source_comments: false, retain_sourcepos: false, repeats_title: false) render_options = { unsafe: true } # Sourcepos wires checkboxes and structural comment targets to source; # it is stripped after generating trusted interaction metadata. @@ -83,7 +86,19 @@ def render_markdown(content, interactive: true, footnote_prefix: nil, footnotes: result = wrap_data_tables(result) if data_tables result = wrap_expandable_images(result) if expandable_images - tag.div(result.html_safe, class: "markdown-rendered", data: { controller: "coplan--mermaid coplan--syntax-highlight" }) + tag.div(result.html_safe, class: class_names("markdown-rendered", "markdown-rendered--repeats-title": repeats_title), data: { controller: "coplan--mermaid coplan--syntax-highlight" }) + end + + # True when the content opens with an ATX H1 whose text is exactly the + # plan title. The page header already shows the title, so the reader + # takes that H1 out of view (markdown.css). Plain text only: a heading + # with inline markup never matches, which errs toward showing it. + def repeats_title?(content, title) + return false if title.blank? + + first_line = content.to_s.each_line.find { |line| line.strip.present? } + heading = first_line&.match(/\A {0,3}#[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*$/)&.[](1) + heading.present? && heading.squish == title.squish end # Replaces `@username` produced by diff --git a/engine/app/helpers/coplan/slideshows_helper.rb b/engine/app/helpers/coplan/slideshows_helper.rb index 7b6dfc19..685fc3f9 100644 --- a/engine/app/helpers/coplan/slideshows_helper.rb +++ b/engine/app/helpers/coplan/slideshows_helper.rb @@ -62,11 +62,15 @@ def render_slideshow(content, interactive: true, theme: "coplan", definitions: n # Render prose and any number of decks, then align every fragment against # one whole-plan render. This keeps footnotes, link definitions and heading # IDs global rather than restarting them at each region boundary. - def render_content_regions(content, interactive: true) - return render_markdown(content, interactive:, footnotes: :exclude, source_comments: true) unless content.include?("::: {.presentation") + # + # title: the plan title, so prose that opens by repeating it can be marked + # (MarkdownHelper#repeats_title?). + def render_content_regions(content, interactive: true, title: nil) + repeats_title = repeats_title?(content, title) + return render_markdown(content, interactive:, footnotes: :exclude, source_comments: true, repeats_title:) unless content.include?("::: {.presentation") result = ContentRegions::Split.call(content) - return render_markdown(content, interactive:, footnotes: :exclude, source_comments: true) unless result.regions.any? { |region| region.kind == :presentation } + return render_markdown(content, interactive:, footnotes: :exclude, source_comments: true, repeats_title:) unless result.regions.any? { |region| region.kind == :presentation } definitions = Slideshows::Split.call(result.canonical_source).definition_blocks deck_number = 0 @@ -101,7 +105,8 @@ def render_content_regions(content, interactive: true) end_line: region.start_line + region.source.count("\n")) preamble = deck_preamble(definitions, fragment) render_markdown(preamble + region.source, interactive:, footnotes: :exclude, retain_sourcepos: interactive, - line_offset: region.start_line - 1 - preamble.count("\n")) + line_offset: region.start_line - 1 - preamble.count("\n"), + repeats_title: repeats_title && region.equal?(result.regions.first)) end end rendered = Nokogiri::HTML::DocumentFragment.parse(safe_join(fragments)) 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..eba0f787 --- /dev/null +++ b/engine/app/javascript/controllers/coplan/app_theme_controller.js @@ -0,0 +1,81 @@ +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. + */ +export default class extends Controller { + static targets = ["error"] + static values = { url: String, default: String } + + connect() { + this.saved = this.element.querySelector("input[name='app_theme']:checked")?.value + } + + // Saves go out one at a time, in the order they were picked, 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("") + + const save = this.saving = (this.saving || Promise.resolve()) + .then(() => this._save(choice)) + .then(ok => { + if (ok) this.saved = choice + return ok + }) + save.then(ok => { + if (save !== this.saving) return + this.saving = null + if (ok) return + + if (this.saved) { + const restored = this.element.querySelector(`input[name='app_theme'][value='${this.saved}']`) + if (restored) restored.checked = true + this._apply(this.saved) + } + 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 + } + } + + _apply(choice) { + const theme = choice === "default" ? this.defaultValue : choice + if (document.documentElement.dataset.appTheme === theme) return + + document.documentElement.dataset.appTheme = theme + window.dispatchEvent(new CustomEvent("coplan:theme-changed")) + } + + _setError(message) { + this.errorTarget.textContent = message + this.errorTarget.hidden = message === "" + } +} diff --git a/engine/app/models/coplan/themes.rb b/engine/app/models/coplan/themes.rb index a135b880..8627b2bd 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) diff --git a/engine/app/models/coplan/user.rb b/engine/app/models/coplan/user.rb index 042aba8e..290babb5 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 host can retire a theme, and the + # sign-in sync must still save users who picked it. Their pages fall back + # to the default (ApplicationHelper#coplan_app_theme). + 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/plans/_content_body.html.erb b/engine/app/views/coplan/plans/_content_body.html.erb index 608d466a..0d0c1116 100644 --- a/engine/app/views/coplan/plans/_content_body.html.erb +++ b/engine/app/views/coplan/plans/_content_body.html.erb @@ -7,6 +7,6 @@ content-mutation broadcast. skip_digest avoids the template-digest cost on write paths; the explicit RENDER_CACHE_VERSION key handles renderer changes instead. -%> -<% cache [CoPlan::EmbedDomain.order(:hostname).pluck(:hostname), request&.host, "coplan/plan-content-body", CoPlan::MarkdownHelper::RENDER_CACHE_VERSION, plan.id, plan.current_plan_version&.content_sha256 || plan.current_revision], skip_digest: true do %> - <%= render_content_regions(plan.current_content) %> +<% cache [CoPlan::EmbedDomain.order(:hostname).pluck(:hostname), request&.host, "coplan/plan-content-body", CoPlan::MarkdownHelper::RENDER_CACHE_VERSION, plan.id, plan.title, plan.current_plan_version&.content_sha256 || plan.current_revision], skip_digest: true do %> + <%= render_content_regions(plan.current_content, title: plan.title) %> <% end %> 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..6d5e7d35 --- /dev/null +++ b/engine/app/views/coplan/settings/settings/_appearance.html.erb @@ -0,0 +1,60 @@ +<%# 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/config/routes.rb b/engine/config/routes.rb index c2aaf2e9..b2245dd1 100644 --- a/engine/config/routes.rb +++ b/engine/config/routes.rb @@ -93,6 +93,7 @@ resources :tokens, only: [ :index, :create, :destroy ] get "agent_sessions", to: "tokens#sessions" patch "theme", to: "settings#update_theme" + patch "app_theme", to: "settings#update_app_theme" patch "voice_hotkey", to: "settings#update_voice_hotkey" end diff --git a/engine/db/migrate/20261030000000_add_app_theme_to_coplan_users.rb b/engine/db/migrate/20261030000000_add_app_theme_to_coplan_users.rb new file mode 100644 index 00000000..76fb8413 --- /dev/null +++ b/engine/db/migrate/20261030000000_add_app_theme_to_coplan_users.rb @@ -0,0 +1,6 @@ +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/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 7de979af..852391a5 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -1,6 +1,44 @@ require "rails_helper" RSpec.describe CoPlan::ApplicationHelper, type: :helper do + describe "#coplan_app_theme" do + around do |example| + original = CoPlan.configuration.default_theme + example.run + ensure + CoPlan.configuration.default_theme = original + end + + def viewing_as(user) + helper.define_singleton_method(:signed_in?) { user.present? } + helper.define_singleton_method(:current_user) { user } + end + + it "uses the host's default for signed-out viewers and viewers on Default" do + CoPlan.configuration.default_theme = "neutral" + viewing_as(nil) + expect(helper.coplan_app_theme).to eq("neutral") + viewing_as(build(:coplan_user, app_theme: "default")) + expect(helper.coplan_app_theme).to eq("neutral") + end + + it "uses the viewer's pick, and the host's default when that pick no longer exists" do + viewing_as(build(:coplan_user, app_theme: "neutral")) + expect(helper.coplan_app_theme).to eq("neutral") + viewing_as(build(:coplan_user, app_theme: "retired")) + expect(helper.coplan_app_theme).to eq("classic") + end + + it "reports the pick Settings shows, with a retired theme as Default" do + viewing_as(build(:coplan_user, app_theme: "neutral")) + expect(helper.coplan_app_theme_choice).to eq("neutral") + viewing_as(build(:coplan_user, app_theme: "retired")) + expect(helper.coplan_app_theme_choice).to eq("default") + viewing_as(nil) + expect(helper.coplan_app_theme_choice).to eq("default") + end + end + describe "environment logo treatments" do it "uses green artwork and matching favicons in development" do allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new("development")) diff --git a/spec/helpers/markdown_helper_spec.rb b/spec/helpers/markdown_helper_spec.rb index bf106700..a54b6624 100644 --- a/spec/helpers/markdown_helper_spec.rb +++ b/spec/helpers/markdown_helper_spec.rb @@ -373,4 +373,26 @@ expect(html).to include(" h1").text).to include("Outbox") + expect(doc.at_css(".markdown-rendered--repeats-title").ancestors(".deck-region")).to be_empty + end + it "renders prose and two independent decks in source order" do source = "Intro\n\n::: {.presentation}\n\n# First\n\n---\n\n# Second\n\n:::\n\nBetween\n\n::: {.presentation}\n\n# Third\n\n:::\n\nEnd" doc = Nokogiri::HTML::DocumentFragment.parse(helper.render_content_regions(source)) diff --git a/spec/models/themes_spec.rb b/spec/models/themes_spec.rb index 1d185d3b..1aa8bd67 100644 --- a/spec/models/themes_spec.rb +++ b/spec/models/themes_spec.rb @@ -24,6 +24,12 @@ expect(described_class.default).to eq(paper) end + it "ships Neutral as an opt-in theme" do + expect(described_class.find("neutral")).to have_attributes(name: "Neutral") + CoPlan.configuration.default_theme = "neutral" + expect(described_class.default.key).to eq("neutral") + end + it "falls back to Classic for an unknown theme" do CoPlan.configuration.default_theme = "hotdog-stand" expect(described_class.default.key).to eq("classic") diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index d9278459..504242ce 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -74,6 +74,26 @@ expect(user.voice_hotkey).to eq("alt") end + it "defaults the app theme to the site default and accepts every theme" do + expect(described_class.new.app_theme).to eq("default") + expect(described_class::APP_THEMES).to eq([ "default", *CoPlan::Themes.keys ]) + CoPlan::Themes.keys.each do |key| + expect(build(:coplan_user, app_theme: key)).to be_valid + end + end + + it "rejects an unknown app theme" do + user = build(:coplan_user, app_theme: "hotdog-stand") + expect(user).not_to be_valid + expect(user.errors[:app_theme]).to be_present + end + + it "still saves a user whose stored theme the host has since retired" do + user = create(:coplan_user) + user.update_column(:app_theme, "retired") + expect(user.reload.update(name: "Renamed")).to be(true) + end + it "validates allowed settings" do user = build(:coplan_user, theme_preference: "invalid", voice_hotkey: "F13") expect(user).not_to be_valid diff --git a/spec/requests/settings_spec.rb b/spec/requests/settings_spec.rb index 8ad2317b..a95cb76d 100644 --- a/spec/requests/settings_spec.rb +++ b/spec/requests/settings_spec.rb @@ -23,6 +23,49 @@ get settings_root_path expect(response.parsed_body.at_css("html")["data-app-theme"]).to eq("classic") end + + it "uses the viewer's own pick over the host's default" do + alice.update!(app_theme: "neutral") + get settings_root_path + expect(response.parsed_body.at_css("html")["data-app-theme"]).to eq("neutral") + end + + it "follows a changed host default for viewers on Default" do + CoPlan.configuration.default_theme = "neutral" + get settings_root_path + expect(response.parsed_body.at_css("html")["data-app-theme"]).to eq("neutral") + expect(response.parsed_body.at_css("input[name=app_theme][value=default][checked]")).to be_present + expect(response.body).to include("Default (Neutral)") + end + + it "falls back to the host's default for a stored theme that no longer exists" do + CoPlan.configuration.default_theme = "neutral" + alice.update_column(:app_theme, "retired") + get settings_root_path + expect(response.parsed_body.at_css("html")["data-app-theme"]).to eq("neutral") + expect(response.parsed_body.at_css("input[name=app_theme][value=default][checked]")).to be_present + end + end + + describe "PATCH /settings/app_theme" do + it "persists a known theme" do + patch settings_app_theme_path, params: { app_theme: "neutral" } + expect(response).to have_http_status(:ok) + expect(alice.reload.app_theme).to eq("neutral") + end + + it "persists Default" do + alice.update!(app_theme: "neutral") + patch settings_app_theme_path, params: { app_theme: "default" } + expect(response).to have_http_status(:ok) + expect(alice.reload.app_theme).to eq("default") + end + + it "rejects unknown themes" do + patch settings_app_theme_path, params: { app_theme: "hotdog-stand" } + expect(response).to have_http_status(:unprocessable_content) + expect(alice.reload.app_theme).to eq("default") + end end describe "PATCH /settings/theme" do diff --git a/spec/system/app_theme_spec.rb b/spec/system/app_theme_spec.rb new file mode 100644 index 00000000..7e32211f --- /dev/null +++ b/spec/system/app_theme_spec.rb @@ -0,0 +1,182 @@ +require "rails_helper" + +# The app theme (docs/THEMES.md): picked in Settings, applied without a +# reload, and Neutral's reading look. Classic is covered by every other +# system spec, which runs with the default theme. +RSpec.describe "App themes", type: :system do + let(:user) { create(:coplan_user, email: "theme-picker@example.com") } + let(:content) do + <<~MD + # Durable outbox + + Partner webhooks leave the request **that caused them**. + + ## Design + + Write one row per event and deliver it later. + + ```ruby + Outbox.create!(event: "order.paid") + ``` + MD + end + let(:plan) do + create(:plan, :published, title: "Durable outbox", created_by_user: user).tap do |p| + p.current_plan_version.update_columns(content_markdown: content) + end + end + + def sign_in(user) + visit sign_in_path + fill_in "Email address", with: user.email + click_button "Sign In" + expect(page).to have_current_path(root_path) + end + + def app_theme_attribute = page.evaluate_script("document.documentElement.dataset.appTheme") + + def style(selector, property, pseudo = nil) + page.evaluate_script("getComputedStyle(document.querySelector(#{selector.to_json}), #{pseudo.to_json}).getPropertyValue(#{property.to_json})") + end + + before { sign_in(user) } + + it "switches the theme as it's picked, saves it, and resolves Default to the site default" do + visit settings_root_path + expect(page).to have_css(".segmented__option:has(input:checked)", text: "Default (Classic)") + expect(app_theme_attribute).to eq("classic") + page.execute_script("window.themeChanges = 0; window.addEventListener('coplan:theme-changed', () => window.themeChanges++)") + + find(".segmented__option", text: "Neutral").click + + expect(app_theme_attribute).to eq("neutral") + expect(page.evaluate_script("window.themeChanges")).to eq(1) + expect(style("body", "font-family")).to start_with("system-ui") + Timeout.timeout(5) { sleep 0.1 until user.reload.app_theme == "neutral" } + + visit settings_root_path + expect(page).to have_css(".segmented__option:has(input:checked)", text: "Neutral") + expect(app_theme_attribute).to eq("neutral") + + find(".segmented__option", text: "Default (Classic)").click + expect(app_theme_attribute).to eq("classic") + Timeout.timeout(5) { sleep 0.1 until user.reload.app_theme == "default" } + end + + it "puts the old choice back when the save fails" do + visit settings_root_path + page.execute_script(<<~JS) + const fetch = window.fetch + window.fetch = (url, options) => String(url).includes("app_theme") ? Promise.resolve(new Response("", { status: 500 })) : fetch(url, options) + JS + + find(".segmented__option", text: "Neutral").click + + expect(page).to have_content("Couldn't save that") + expect(page).to have_css(".segmented__option:has(input:checked)", text: "Default (Classic)") + expect(app_theme_attribute).to eq("classic") + expect(user.reload.app_theme).to eq("default") + end + + it "keeps quick picks in order and falls back to the last one that saved" do + visit settings_root_path + page.execute_script(<<~JS) + const fetch = window.fetch + window.fetch = (url, options) => { + if (!String(url).includes("app_theme")) return fetch(url, options) + if (options.body.includes("neutral")) return new Promise(resolve => setTimeout(resolve, 400)).then(() => fetch(url, options)) + return Promise.resolve(new Response("", { status: 500 })) + } + JS + + find(".segmented__option", text: "Neutral").click + find(".segmented__option", exact_text: "Classic").click + expect(app_theme_attribute).to eq("classic") + + expect(page).to have_content("Couldn't save that") + expect(page).to have_css(".segmented__option:has(input:checked)", text: "Neutral") + expect(app_theme_attribute).to eq("neutral") + expect(user.reload.app_theme).to eq("neutral") + end + + describe "Neutral" do + before { user.update!(app_theme: "neutral") } + + it "sets the document flat on the page with the reading type, in both modes" do + visit plan_page_path(plan) + expect(page).to have_css(".plan-reader .markdown-rendered pre[lang='ruby']") + + %w[light dark].each do |mode| + page.execute_script("document.documentElement.dataset.theme = '#{mode}'") + + expect(style(".plan-document", "box-shadow")).to eq("none") + expect(style(".plan-document", "border-top-width")).to eq("0px") + expect(style(".plan-document", "background-color")).to eq(style("body", "background-color")) + expect(style("body", "background-image")).to eq("none") + expect(style(".site-nav", "backdrop-filter")).to eq("none") + expect(style(".plan-reader .markdown-rendered p", "line-height")).to eq("25.6px") + expect(style(".plan-reader .markdown-rendered", "font-family")).to start_with("system-ui") + expect(style(".plan-reader .markdown-rendered h2", "font-weight")).to eq("500") + expect(style(".plan-reader .markdown-rendered strong", "font-weight")).to eq("600") + expect(style(".page-header__title", "font-size")).to eq("28px") + expect(style(".plan-reader .markdown-rendered pre", "border-top-width")).to eq("0px") + expect(style(".plan-reader .markdown-rendered pre[lang]", "text-align", "::before")).to eq("right") + expect(style(".plan-reader .markdown-rendered pre[lang]", "background-image", "::before")).to eq("none") + end + end + + it "hides an opening H1 that repeats the plan title, but only in Neutral" do + visit plan_page_path(plan) + h1 = ".plan-reader .markdown-rendered--repeats-title > h1" + expect(page).to have_css(h1, text: "Durable outbox", visible: :all) + expect(style(h1, "position")).to eq("absolute") + expect(style(h1, "clip-path")).to eq("inset(50%)") + + user.update!(app_theme: "classic") + visit plan_page_path(plan) + expect(page).to have_css(h1, text: "Durable outbox") + expect(style(h1, "position")).to eq("static") + end + + it "sets editor paragraphs like the reader's" do + visit plan_page_path(plan) + reader = %w[font-family font-size line-height margin-bottom].index_with { |property| style(".plan-reader .markdown-rendered p", property) } + + visit plan_edit_page_path(plan) + expect(page).to have_css(".document-editor__body .markdown-rendered p", wait: 10) + editor = reader.keys.index_with { |property| style(".document-editor__body .markdown-rendered p", property) } + + expect(editor).to eq(reader) + end + + it "keeps deck diagrams in the deck's font while document diagrams follow the theme" do + plan.current_plan_version.update_columns(content_markdown: <<~MD) + ```mermaid + flowchart LR + Request --> Outbox + ``` + + ::: {.presentation} + + # Slide + + ```mermaid + flowchart LR + Deck --> Slide + ``` + + ::: + MD + visit plan_page_path(plan) + expect(page).to have_css(".mermaid-diagram .nodeLabel", count: 4, wait: 30) + + fonts = page.evaluate_script(<<~JS) + Object.fromEntries([...document.querySelectorAll(".mermaid-diagram .nodeLabel")].map(label => + [label.closest(".deck") ? "deck" : "document", getComputedStyle(label).fontFamily])) + JS + document_font, deck_font = fonts.values_at("document", "deck") + expect(document_font).to start_with("system-ui") + expect(deck_font).to start_with("Lexend") + end + end +end diff --git a/spec/system/changed_sections_ux_spec.rb b/spec/system/changed_sections_ux_spec.rb index f4a88cdc..fc99d387 100644 --- a/spec/system/changed_sections_ux_spec.rb +++ b/spec/system/changed_sections_ux_spec.rb @@ -207,10 +207,11 @@ def broadcast_section_update(keys:, revision: 3, rewritten: false, rename: false expect(page).to have_no_css(".section-updated") end - it "keeps mobile dots inside the card without a summary banner" do + it "keeps mobile dots inside the document without a summary banner" do window = page.current_window original_size = window.size - [ 320, 390, 481 ].each do |width| + [ 320, 390, 481 ].product(%w[classic neutral]).each do |width, app_theme| + viewer.update!(app_theme: app_theme) %w[light dark].each do |theme| window.resize_to(width, 853) CoPlan::PlanViewer.find_by!(plan: plan, user: viewer).update!(last_seen_at: 1.hour.ago) From 8d57e4c3235e27611356026e47e61578946fd60c Mon Sep 17 00:00:00 2001 From: Salman Mohammed Date: Fri, 30 Oct 2026 15:49:02 -0400 Subject: [PATCH 02/15] Add an Appearance menu to the top bar A half-filled circle beside notifications opens a small popover with the light/dark mode (System, Light, Dark) and the app theme (Classic or Neutral, the one showing checked). Picks apply at once, save in the background like Settings, and the popover stays open to compare. It positions on beforetoggle, so it never paints a frame at the wrong spot. The menu reuses the mode and theme controllers. They now keep each other's radios in step, so the menu and Settings agree when both are on screen, and the app theme controller takes the stored choice from the page, since the menu has no Default option to read it from. Segmented controls also get a visible keyboard focus ring. Co-Authored-By: Claude Opus 5.5 --- docs/THEMES.md | 6 ++ .../assets/stylesheets/coplan/navigation.css | 91 +++++++++++++++++++ .../assets/stylesheets/coplan/responsive.css | 24 ++++- .../coplan/search-and-shortcuts.css | 1 + .../assets/stylesheets/coplan/settings.css | 5 + .../coplan/app_theme_controller.js | 55 +++++++++-- .../controllers/coplan/theme_controller.js | 19 +++- .../settings/settings/_appearance.html.erb | 3 +- .../coplan/shared/_appearance_menu.html.erb | 51 +++++++++++ .../views/layouts/coplan/application.html.erb | 9 +- spec/requests/settings_spec.rb | 9 ++ spec/system/app_theme_spec.rb | 53 +++++++++++ 12 files changed, 310 insertions(+), 16 deletions(-) create mode 100644 engine/app/views/coplan/shared/_appearance_menu.html.erb diff --git a/docs/THEMES.md b/docs/THEMES.md index 06269dff..59746140 100644 --- a/docs/THEMES.md +++ b/docs/THEMES.md @@ -45,6 +45,12 @@ 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`). 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/responsive.css b/engine/app/assets/stylesheets/coplan/responsive.css index ee1074ed..3a04f5d8 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; } @@ -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/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 92abc83b..c7b6f76d 100644 --- a/engine/app/assets/stylesheets/coplan/settings.css +++ b/engine/app/assets/stylesheets/coplan/settings.css @@ -248,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/javascript/controllers/coplan/app_theme_controller.js b/engine/app/javascript/controllers/coplan/app_theme_controller.js index eba0f787..937182bf 100644 --- a/engine/app/javascript/controllers/coplan/app_theme_controller.js +++ b/engine/app/javascript/controllers/coplan/app_theme_controller.js @@ -11,13 +11,35 @@ import { Controller } from "@hotwired/stimulus" * 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. */ export default class extends Controller { static targets = ["error"] - static values = { url: String, default: String } + // 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.element.querySelector("input[name='app_theme']:checked")?.value + 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, so saved @@ -27,11 +49,15 @@ export default class extends Controller { const choice = event.target.value this._apply(choice) this._setError("") + this._announce(choice, false) const save = this.saving = (this.saving || Promise.resolve()) .then(() => this._save(choice)) .then(ok => { - if (ok) this.saved = choice + if (ok) { + this.saved = choice + this._announce(choice, true) + } return ok }) save.then(ok => { @@ -40,9 +66,9 @@ export default class extends Controller { if (ok) return if (this.saved) { - const restored = this.element.querySelector(`input[name='app_theme'][value='${this.saved}']`) - if (restored) restored.checked = true + this._check(this.saved) this._apply(this.saved) + this._announce(this.saved, false) } this._setError("Couldn't save that — try again.") }) @@ -66,8 +92,24 @@ export default class extends Controller { } } + // 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 = choice === "default" ? this.defaultValue : choice + const theme = this._resolve(choice) if (document.documentElement.dataset.appTheme === theme) return document.documentElement.dataset.appTheme = theme @@ -75,6 +117,7 @@ export default class extends Controller { } _setError(message) { + if (!this.hasErrorTarget) return this.errorTarget.textContent = message this.errorTarget.hidden = message === "" } 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/views/coplan/settings/settings/_appearance.html.erb b/engine/app/views/coplan/settings/settings/_appearance.html.erb index 6d5e7d35..e0e328d2 100644 --- a/engine/app/views/coplan/settings/settings/_appearance.html.erb +++ b/engine/app/views/coplan/settings/settings/_appearance.html.erb @@ -7,7 +7,8 @@
+ data-coplan--app-theme-default-value="<%= site_default.key %>" + data-coplan--app-theme-saved-value="<%= coplan_app_theme_choice %>">
Theme

+ + +

+ 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 %>