Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 6 additions & 3 deletions docs/HOST_APP_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 |

Expand Down Expand Up @@ -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
```
Expand Down
82 changes: 62 additions & 20 deletions docs/THEMES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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, <Classic value>)`. 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 `<html data-app-theme="classic">`. 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 `<html data-app-theme="classic">`
(`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`).
Expand All @@ -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/<key>.css` and import it
last in `coplan/application.css`.
3. Set tokens and hooks under `:root[data-app-theme="<key>"]`. For dark mode,
repeat them under `:root[data-app-theme="<key>"][data-theme="dark"]` and
inside `@media (prefers-color-scheme: dark)` under
`:root[data-app-theme="<key>"]:not([data-theme])`.
4. Set custom properties only. A hook can also be set on a component, for
example `:root[data-app-theme="<key>"] .plan-document { --color-surface: … }`.
3. Set tokens and hooks under `:root[data-app-theme="<key>"]`. Give each
color its light and dark value with `light-dark(<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="<key>"] .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.
Expand All @@ -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

Expand All @@ -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 |
Expand All @@ -71,28 +108,33 @@ 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.

## 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="<key>"][data-theme="dark"]` and
one inside `@media (prefers-color-scheme: dark)` under
`:root[data-app-theme="<key>"]: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.
1 change: 1 addition & 0 deletions engine/app/assets/stylesheets/coplan/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@
@import url("./review.css");
@import url("./agent-access.css");
@import url("./not-found.css");
@import url("./themes/neutral.css");
13 changes: 13 additions & 0 deletions engine/app/assets/stylesheets/coplan/deck.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
15 changes: 13 additions & 2 deletions engine/app/assets/stylesheets/coplan/markdown.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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);
Expand Down Expand Up @@ -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;
}
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down
91 changes: 91 additions & 0 deletions engine/app/assets/stylesheets/coplan/navigation.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions engine/app/assets/stylesheets/coplan/plan-header.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading