From bd30add28a26f373125dd5f64623a0796d42c7a0 Mon Sep 17 00:00:00 2001 From: Salman Mohammed Date: Fri, 30 Oct 2026 11:00:45 -0400 Subject: [PATCH 1/5] Add a reading specimen to the demo library A realistic agent-drafted design doc for judging the reading experience in both themes: prose, headings, lists, tasks, quote, table, footnote, image, SQL/Ruby/YAML/diff code, a long scrolling line, and flowchart and sequence diagrams. Seeds open, agent-answered, resolved, diagram-node, table-cell, and general discussions so highlights are in place. Co-Authored-By: Claude Opus 5.5 --- docs/DEMO_LIBRARY.md | 1 + engine/db/demo/content/reading-specimen.md | 184 +++++++++++++++++++++ engine/lib/coplan/development_seed.rb | 47 +++++- spec/lib/development_seed_spec.rb | 34 +++- 4 files changed, 262 insertions(+), 4 deletions(-) create mode 100644 engine/db/demo/content/reading-specimen.md diff --git a/docs/DEMO_LIBRARY.md b/docs/DEMO_LIBRARY.md index 914e979a..3f553d25 100644 --- a/docs/DEMO_LIBRARY.md +++ b/docs/DEMO_LIBRARY.md @@ -55,6 +55,7 @@ shipped types, including Scratchpad, PRD, Test Plan, and Handoff. | reference-gallery | All five reference types, a resolved CoPlan target, explicit/extracted references, named citations, repeated URLs, footnotes | | table-gallery | Precise cell discussion, repeated labels, empty values, links, wide expandable data | | formatting-gallery | Headings, editable template notes hidden while reading, emphasis, quotes, nested lists, tasks, two code languages, image, mention, footnote | +| reading-specimen | A realistic design doc for judging reading in both themes: prose, headings, lists, tasks, quote, table, footnote, image, SQL/Ruby/diff/YAML code, a long scrolling line, flowchart and sequence diagrams, plus open, agent-answered, resolved, diagram-node, table-cell, and general discussions | | image-resizing | Resize and restore image widths, wrap prose from the top, Shift+Enter beside, Enter below | Flowcharts currently support source-backed node and edge comments. Other diff --git a/engine/db/demo/content/reading-specimen.md b/engine/db/demo/content/reading-specimen.md new file mode 100644 index 00000000..a588d94c --- /dev/null +++ b/engine/db/demo/content/reading-specimen.md @@ -0,0 +1,184 @@ +Partner webhooks are delivered from the request that caused them. When a partner endpoint is slow, our checkout requests wait on it, and when the process restarts mid-send the event is gone. This plan moves delivery to a **durable outbox**: the request writes one row in the same transaction as the business change, and a worker delivers it with bounded retries. Nothing about the payloads or signatures changes for partners.[^incident] + +The plan is written the way most CoPlan documents are — drafted with an agent, then corrected by the people who own the system. [@alex](mention:alex) owns the worker fleet; [@noura](mention:noura) reviews anything that touches signing keys. + +## Problem + +Three things go wrong today, and they compound: + +1. **Latency leaks into checkout.** Delivery runs inline, so a partner timing out at 10 seconds holds a checkout request for 10 seconds. +2. **Events are lost on restart.** A deploy that recycles a process mid-send drops the event. We have no record that it was ever owed. +3. **Retries are invisible.** The only retry is the partner's own re-fetch, and *we* can't tell a partner who never received an event from one who ignored it. + +None of these is new. What changed is volume: partner count doubled this year, and the slowest endpoint now sets the tail for everyone. + +## Goals and non-goals + +- Every committed business event is delivered *at least once*, or is visibly dead-lettered. +- Checkout latency no longer depends on any partner endpoint. +- Partners see the same payload, headers, and signature as today. +- ~~Exactly-once delivery~~ — partners already de-duplicate on `event_id`, so at-least-once is enough. + +### Out of scope + +Replaying historical events, a partner-facing delivery dashboard, and changing the signature scheme. Each is worth doing; none blocks this. + +## Design + +### The outbox row + +The request writes the event to `webhook_outbox` inside the transaction that commits the order. If the transaction rolls back, the event never existed. If it commits, the row is the promise. + +```sql +CREATE TABLE webhook_outbox ( + id CHAR(36) NOT NULL PRIMARY KEY, + partner_id CHAR(36) NOT NULL, + event_type VARCHAR(64) NOT NULL, + payload JSON NOT NULL, + attempts INT NOT NULL DEFAULT 0, + next_due_at DATETIME(6) NOT NULL, + delivered_at DATETIME(6) NULL, + dead_at DATETIME(6) NULL, + INDEX index_webhook_outbox_on_next_due_at (next_due_at) +); +``` + +### Delivery flow + +```mermaid +flowchart TD + Commit[Order committed] --> Outbox[(webhook_outbox)] + Outbox --> Worker[Delivery worker] + Worker -->|2xx| Delivered[Mark delivered] + Worker -->|timeout or 5xx| Backoff[Schedule retry] + Backoff --> Worker + Worker -->|attempts exhausted| Dead[Dead letter] +``` + +A worker claims due rows with `SELECT … FOR UPDATE SKIP LOCKED`, sends them, and records the outcome. Two workers never claim the same row, and a crashed worker's claim expires with its transaction. + +```ruby +class WebhookDelivery + MAX_ATTEMPTS = 8 + TIMEOUT = 5.seconds + + def call(row) + response = client.post(row.partner.endpoint_url, body: row.payload, headers: signed_headers(row), timeout: TIMEOUT) + response.success? ? row.delivered! : retry_later(row, reason: response.status) + rescue Net::OpenTimeout, Net::ReadTimeout => error + retry_later(row, reason: error.class.name) + end + + private + + def retry_later(row, reason:) + return row.dead!(reason: reason) if row.attempts + 1 >= MAX_ATTEMPTS + + row.update!(attempts: row.attempts + 1, next_due_at: Backoff.next(row.attempts).from_now) + end +end +``` + +#### Backoff schedule + +Retries use exponential backoff with full jitter, capped at one hour. Eight attempts span roughly a day, which matches the partner contract's "best effort for 24 hours" language. + +| Attempt | Base delay | With jitter | Cumulative (worst case) | +| --- | --- | --- | --- | +| 1 | 30 s | 0–30 s | 30 s | +| 2 | 2 min | 0–2 min | 2.5 min | +| 3 | 8 min | 0–8 min | 10.5 min | +| 4 | 30 min | 0–30 min | 40.5 min | +| 5–8 | 1 h | 0–1 h | about 4.7 h | + +> A partner that is down for a day should come back to a full set of events, in order per order ID, without anyone paging us. +> +> — from the partner contract, section 4.2 + +### Sequence for one event + +```mermaid +sequenceDiagram + participant C as Checkout + participant DB as Database + participant W as Worker + participant P as Partner + C->>DB: commit order + outbox row + C-->>C: respond to buyer + W->>DB: claim due rows (SKIP LOCKED) + W->>P: POST signed payload + P-->>W: 503 + W->>DB: attempts += 1, next_due_at = +2 min + W->>P: POST signed payload (retry) + P-->>W: 200 + W->>DB: delivered_at = now +``` + +### What changes in the existing call path + +```diff + class OrdersController < ApplicationController + def create + order = Order.transaction do + Order.create!(order_params).tap do |order| +- PartnerWebhooks.deliver_now(order, event: "order.created") ++ WebhookOutbox.enqueue!(order, event: "order.created") + end + end + render json: order, status: :created + end + end +``` + +Configuration stays in one place. The values below are the starting point; the rollout section explains when each moves. + +```yaml +webhooks: + outbox: + enabled: false # flipped per partner during rollout + batch_size: 50 + worker_concurrency: 4 + claim_timeout_seconds: 30 + delivery: + timeout_seconds: 5 + max_attempts: 8 +``` + +A worker logs one structured line per attempt. Long values are kept on one line on purpose, so the block has to scroll sideways rather than wrap: + +```text +2026-10-29T14:02:11.482Z level=warn event=webhook.retry partner_id=6f1c2a9e-8d4b-4c1e-9a7f-1b2c3d4e5f60 event_id=evt_01JB8Z3K4M5N6P7Q8R9S0T1U2V attempt=3 status=503 next_due_at=2026-10-29T14:10:11Z +``` + +
+Why not a message broker? + +We considered publishing to the existing Kafka cluster instead of a table. It would scale further, but it splits the commit in two: the order lands in MySQL and the event lands in Kafka, and either can fail alone. The outbox keeps one transaction. If volume outgrows the table, the outbox becomes the source a relay publishes from — the same pattern, one hop later. + +
+ +## Risks + +- **Table growth.** Delivered rows are deleted after 7 days by a nightly job. At current volume that is about 40 million rows at peak. +- **Head-of-line blocking.** One slow partner must not starve the others, so workers claim per partner with a concurrency cap of `2`. +- **Duplicate sends after a crash.** A worker that sends and then dies before recording the result will send again. Partners de-duplicate on `event_id`; we confirmed this with the top 20 partners by volume. + +--- + +## Rollout + +- [x] Ship the table and the worker behind `webhooks.outbox.enabled` +- [x] Dual-write for internal test partners +- [ ] Enable for the five partners with the slowest p99 +- [ ] Enable for everyone and delete `deliver_now` + +Each step waits one week and is reversible by flipping the flag back. Rows written while the flag was on are still delivered after it is turned off. + +![CoPlan logo]({{demo_logo_url}}) + +## Open questions + +1. Should dead-lettered events notify the partner by email, or only appear in our admin view? +2. Do we keep per-order ordering, or is per-partner ordering enough? Ordering per order ID costs a second index. + +[^incident]: The October 14 checkout slowdown traced to a single partner endpoint returning after 9.8 seconds. Inline delivery held 1,200 checkout requests open for the duration. diff --git a/engine/lib/coplan/development_seed.rb b/engine/lib/coplan/development_seed.rb index 299ba685..abfaa26c 100644 --- a/engine/lib/coplan/development_seed.rb +++ b/engine/lib/coplan/development_seed.rb @@ -95,6 +95,9 @@ module DevelopmentSeed { key: "formatting-gallery", author: "hampton", type: "General", title: "Rich text: headings, tasks, code, quotes, and footnotes", tags: %w[demo markdown], visibility: "published", folder: "Demo content/Rich text", group: "Tables and rich text", showcase: "Markdown formatting, editable template notes hidden while reading, reversible bullet typing, list exit, task toggles, code languages, image, mention, and footnotes" }, + { key: "reading-specimen", author: "hampton", type: "Design Doc", title: "Reading specimen: webhook delivery through a durable outbox", + tags: %w[demo markdown reading], visibility: "published", folder: "Demo content/Rich text", group: "Tables and rich text", + showcase: "A realistic agent-drafted design doc for judging reading: common Markdown elements, SQL, Ruby, YAML, and diff code, a long scrolling line, two Mermaid diagrams, and open, answered, resolved, diagram, table-cell, and general discussions" }, { key: "image-resizing", author: "hampton", type: "General", title: "Images: resize and snap to full width", tags: %w[demo images editor], visibility: "published", folder: "Demo content/Rich text", group: "Tables and rich text", showcase: "Editor corner resizing, text wrapping beside images, Enter to continue below, undo, and snap to natural full width" }, @@ -288,6 +291,7 @@ def call(base_url: ENV.fetch("COPLAN_DEMO_BASE_URL", "http://localhost:3000")) seed_agent_access(users) seed_agent_connections(users) if CoPlan.configuration.agent_connection_enabled? seed_content_discussions(users, plans) + seed_reading_specimen(users, plans) seed_reference_gallery(plans, base_url: base_url) catalog = seed_catalog(users, plans, plan_types, base_url: base_url) seed_writing_history(users, plans, catalog) @@ -870,7 +874,9 @@ def seed_content_discussions(users, plans) [ "class-model", "mermaid_diagram", "classDiagram", "Entire diagram", "Should both adapters expose the same delivery outcome codes?" ], [ "long-title", "mermaid_diagram", "sequenceDiagram", "Entire diagram", "Does the receipt response wait for the region-local audit write?" ], [ "mobile-checkout", "mermaid_diagram", "stateDiagram", "Entire diagram", "Where does an expired receipt transition after reconnecting?" ], - [ "table-gallery", "table_cell", "queued", "Delivery status", "How long can this receipt remain queued before the admin sees a warning?" ] + [ "table-gallery", "table_cell", "queued", "Delivery status", "How long can this receipt remain queued before the admin sees a warning?" ], + [ "reading-specimen", "mermaid_node", "Dead[", "Node Dead", "Who gets paged when a partner's events start dead-lettering?" ], + [ "reading-specimen", "table_cell", "about 4.7 h", "Cumulative (worst case)", "The contract says 24 hours, but this schedule gives up after about five. Is that intentional?" ] ] selections.each do |key, kind, fragment, label, body| plan = plans.fetch(key) @@ -890,6 +896,45 @@ def seed_content_discussions(users, plans) end end + # The prose discussions a reader meets in a real review, so a visual + # change to the document can be judged with its highlights in place. + # Diagram and table-cell threads come from seed_content_discussions. + def seed_reading_specimen(users, plans) + plan = plans.fetch("reading-specimen") + hampton = users.fetch("hampton") + token = seed_agent_token(hampton) + + seed_thread( + plan: plan, user: users.fetch("noura"), + anchor: "Partners see the same payload, headers, and signature as today.", + body: "Signatures include a timestamp. A retry six hours later carries a new one, so partners that reject stale timestamps will see the same event as \"new\". Can we say which timestamp a retry signs?" + ) + + # An open question an agent has already answered. + question = seed_thread( + plan: plan, user: users.fetch("alex"), + anchor: "workers claim per partner with a concurrency cap", + body: "Is 2 per partner enough for the two partners that send us 40% of volume? I'd expect a backlog during their evening peak." + ) + question&.comments&.create!( + body_markdown: "At their peak of 180 events/min and a p50 response of 300 ms, two workers clear about 400/min, so 2 keeps up. " \ + "It falls behind only when their p50 passes 650 ms. I'd keep 2 and alert on per-partner queue age above 5 minutes.", + author_type: "local_agent", author_id: hampton.id, agent_name: token.agent_name, api_token_id: token.id + ) + + seed_thread( + plan: plan, user: users.fetch("sam"), + anchor: "Delivered rows are deleted after 7 days", + resolved_by: hampton, + body: "Support asks about deliveries up to 30 days back. Can we keep delivered rows for 30 days, or copy them to the audit log first?" + ) + + seed_thread( + plan: plan, user: users.fetch("aiko"), + body: "This reads well. Please link the dashboard we'll watch during each rollout step before the first partner is enabled." + ) + end + def seed_reference_gallery(plans, base_url:) plan = plans.fetch("reference-gallery") definition = DOCUMENTS.find { |entry| entry[:key] == "reference-gallery" } diff --git a/spec/lib/development_seed_spec.rb b/spec/lib/development_seed_spec.rb index b1e8dbae..3fdecc4e 100644 --- a/spec/lib/development_seed_spec.rb +++ b/spec/lib/development_seed_spec.rb @@ -371,9 +371,37 @@ def seeded_plan(key) expect(host_plan.reload.plan_type).to eq(custom) end + it "seeds a reading specimen with every content surface and each discussion state" do + described_class.call + plan = seeded_plan("reading-specimen") + content = plan.current_content + expect(content).to include("```sql", "```ruby", "```diff", "```yaml", "```text", "
", "- [x] ", "~~Exactly-once delivery~~", + "[^incident]:", "[@alex](mention:alex)", "\n---\n", described_class.demo_logo_url) + expect(content.scan("```mermaid").size).to eq(2) + + threads = plan.comment_threads + expect(threads.where(anchor_kind: %w[mermaid_node table_cell]).pluck(:anchor_kind).sort).to eq(%w[mermaid_node table_cell]) + ranges = CoPlan::Plans::SourceTargets.new(content).ranges + threads.where.not(anchor_kind: nil).each do |thread| + expect(ranges).to include([ thread.anchor_kind, thread.anchor_start, thread.anchor_end ]) + end + prose = threads.where(anchor_kind: nil) + expect(prose.where.not(anchor_text: nil).where.not(anchor_start: nil).count).to eq(3) + expect(prose.where(anchor_text: nil).count).to eq(1) + expect(prose.pluck(:status).tally).to eq("open" => 3, "resolved" => 1) + answered = prose.detect { |thread| thread.comments.size == 2 } + expect(answered.comments.last.author_type).to eq("local_agent") + + expect(seeded_plan("demo-library").current_content).to include("(/#{plan.url_path})", "`reading-specimen`") + count = CoPlan::Comment.count + described_class.call + expect(CoPlan::Comment.count).to eq(count) + expect(plan.reload.current_revision).to eq(1) + end + it "preserves edited and resolved seeded discussions on re-seeding" do described_class.call - %w[collab-showcase diagram-comments table-gallery].each do |key| + %w[collab-showcase diagram-comments table-gallery reading-specimen].each do |key| plan = seeded_plan(key) plan.comment_threads.each do |thread| thread.comments.first.update!(body_markdown: "My revised feedback") @@ -382,13 +410,13 @@ def seeded_plan(key) end count = CoPlan::CommentThread.count # Simulate a pre-upgrade dataset with edited comments but no UUID map. - %w[collab-showcase diagram-comments table-gallery].each do |key| + %w[collab-showcase diagram-comments table-gallery reading-specimen].each do |key| plan = seeded_plan(key) plan.update!(metadata: plan.metadata.except("development_seed_threads")) end described_class.call expect(CoPlan::CommentThread.count).to eq(count) - %w[collab-showcase diagram-comments table-gallery].each do |key| + %w[collab-showcase diagram-comments table-gallery reading-specimen].each do |key| expect(seeded_plan(key).comment_threads.pluck(:status)).to all(eq("resolved")) end end From 08a869ba3bb7860ead52f561e4c19245f89da26d Mon Sep 17 00:00:00 2001 From: Salman Mohammed Date: Fri, 30 Oct 2026 13:56:15 -0400 Subject: [PATCH 2/5] Add an app theme construct with Classic as the default A theme is a named set of CSS variable values applied with . Classic, the current look, sets no variables: every rule a theme can change reads var(--hook, ), so Classic renders exactly as before. Hosts pick the site default with config.default_theme. Decks reset the hooks to keep their built-in look, and Mermaid can take a per-theme palette from CSS variables. See docs/THEMES.md for the hooks and how to add a theme. Co-Authored-By: Claude Opus 5.5 --- docs/HOST_APP_GUIDE.md | 3 + docs/THEMES.md | 81 +++++++++++++ .../assets/stylesheets/coplan/controls.css | 9 ++ engine/app/assets/stylesheets/coplan/deck.css | 32 +++++- .../stylesheets/coplan/document-resources.css | 6 +- .../stylesheets/coplan/expanded-content.css | 4 +- .../assets/stylesheets/coplan/foundation.css | 4 +- .../assets/stylesheets/coplan/markdown.css | 107 ++++++++++++++---- .../assets/stylesheets/coplan/plan-header.css | 9 ++ .../stylesheets/coplan/presence-and-voice.css | 4 +- .../assets/stylesheets/coplan/responsive.css | 8 +- .../app/assets/stylesheets/coplan/review.css | 44 +++---- .../app/helpers/coplan/application_helper.rb | 6 + .../controllers/coplan/mermaid_controller.js | 73 +++++++++++- engine/app/models/coplan/themes.rb | 25 ++++ .../views/layouts/coplan/application.html.erb | 2 +- engine/lib/coplan/configuration.rb | 8 ++ spec/models/themes_spec.rb | 35 ++++++ spec/requests/settings_spec.rb | 20 ++++ 19 files changed, 416 insertions(+), 64 deletions(-) create mode 100644 docs/THEMES.md create mode 100644 engine/app/models/coplan/themes.rb create mode 100644 spec/models/themes_spec.rb diff --git a/docs/HOST_APP_GUIDE.md b/docs/HOST_APP_GUIDE.md index a4c42ddc..319691fc 100644 --- a/docs/HOST_APP_GUIDE.md +++ b/docs/HOST_APP_GUIDE.md @@ -314,6 +314,9 @@ CoPlan.configure do |config| config.track_event = ->(event, payload) { AnalyticsEvent.create!(name: event, payload: payload) } + + # App theme (optional). One of CoPlan::Themes.keys; see docs/THEMES.md. + config.default_theme = "classic" # default end ``` diff --git a/docs/THEMES.md b/docs/THEMES.md new file mode 100644 index 00000000..b7187fbd --- /dev/null +++ b/docs/THEMES.md @@ -0,0 +1,81 @@ +# App themes + +A theme changes how CoPlan looks, never what it does. It is a named set of +CSS variable values. Light and dark mode are a separate choice, so a theme +defines both. + +Classic is the built-in look: Lexend, glass panels and the document card. It +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. + +## 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)), and +falls back to `classic` for an unknown name. Light and dark stay on +`data-theme`. + +The themes are listed in `CoPlan::Themes::ALL` +(`engine/app/models/coplan/themes.rb`). + +## Adding a theme + +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-ui-theme=""]`. For dark mode, + repeat them under `:root[data-ui-theme=""][data-theme="dark"]` and + inside `@media (prefers-color-scheme: dark)` under + `:root[data-ui-theme=""]:not([data-theme])`. +4. Set custom properties only. A hook can also be set on a component, for + example `:root[data-ui-theme=""] .plan-document { --color-surface: … }`. + +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. + +Decks are fixed artifacts. `.deck` sets every document hook to `initial`, +which brings back the built-in value, so themes never restyle slides. + +## Tokens + +Every token in `foundation.css` `:root` can be overridden, for example +`--color-bg`, `--color-text`, `--font-sans`, `--font-mono`, +`--code-block-bg`, the `--code-*` syntax colors, `--glass-*` and +`--shadow-*`. Keep blue for interaction and orange for attention +([DESIGN.md](DESIGN.md)). + +## Hooks + +| Area | Hooks | +| --- | --- | +| Page | `--page-backdrop` (the body's background image) | +| Glass | `--glass-blur` (panels), `--window-blur` (comment window, review tray, voice button), `--overlay-blur` (modal backdrops) | +| Floating windows | `--window-bg`, `--window-edge`, `--window-shadow`, `--composer-input-bg` | +| Overlays | `--overlay-tint` (new-document and expanded views), `--dialog-backdrop` (review dialog) | +| 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` | +| 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 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` | + +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: +`--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`. + +If any of them is missing, documents keep Mermaid's built-in themes. Decks +always do. diff --git a/engine/app/assets/stylesheets/coplan/controls.css b/engine/app/assets/stylesheets/coplan/controls.css index 2db67fac..06134f1e 100644 --- a/engine/app/assets/stylesheets/coplan/controls.css +++ b/engine/app/assets/stylesheets/coplan/controls.css @@ -107,6 +107,15 @@ box-shadow: var(--shadow-glass); } +/* The plan document's card (theme hooks; docs/THEMES.md). */ +.plan-document.card { + padding: var(--document-padding, var(--space-lg)); + border: var(--document-border, 1px solid var(--card-edge)); + border-radius: var(--document-radius, var(--radius-lg)); + background: var(--document-bg, var(--glass-bg-strong)); + box-shadow: var(--document-shadow, var(--shadow-glass)); +} + /* Status badges */ .badge { display: inline-block; diff --git a/engine/app/assets/stylesheets/coplan/deck.css b/engine/app/assets/stylesheets/coplan/deck.css index 7b1adcb5..99cf2c56 100644 --- a/engine/app/assets/stylesheets/coplan/deck.css +++ b/engine/app/assets/stylesheets/coplan/deck.css @@ -20,6 +20,21 @@ display: flex; flex-direction: column; gap: 2rem; + /* A deck is a fixed artifact: app themes don't restyle it. Setting a + theme hook to initial brings back its built-in value (docs/THEMES.md). + Code keeps the app's original mono stack. */ + --font-mono: "Hack", "SF Mono", SFMono-Regular, ui-monospace, Menlo, monospace; + --strong-weight: initial; + --code-chip-padding: initial; + --code-chip-radius: initial; + --code-chip-size: initial; + --diagram-bg: initial; + --diagram-border: initial; + --diagram-radius: initial; + --blockquote-rule: initial; + --table-radius: initial; + --table-numerals: initial; + --image-outline: initial; } .deck-slide { @@ -135,7 +150,8 @@ scheme. The host flips its --code-* variables with the app's light/dark mode — inside a deck that painted deck-ink text on a near-black panel — so the panel pins every variable the host's code - chrome reads (panel, header, hairlines, highlight tokens). */ + chrome reads (panel, header, hairlines, highlight tokens) and resets + the code-block theme hooks. */ .deck-slide .deck-content pre { font-size: 0.8em; line-height: 1.55; @@ -146,6 +162,20 @@ --color-border: rgb(255 255 255 / 0.09); --code-header-bg: #161b22; --code-header-text: #8b8f98; + --code-block-radius: initial; + --code-block-padding: initial; + --code-block-scrollbar: initial; + --code-label-height: initial; + --code-label-margin: initial; + --code-label-padding: initial; + --code-label-bg: initial; + --code-label-dots: initial; + --code-label-border: initial; + --code-label-radius: initial; + --code-label-font: initial; + --code-label-leading: initial; + --code-label-tracking: initial; + --code-label-align: initial; --code-comment: #7f848e; --code-keyword: #c678dd; --code-entity: #61afef; diff --git a/engine/app/assets/stylesheets/coplan/document-resources.css b/engine/app/assets/stylesheets/coplan/document-resources.css index 8715d22e..0d291bf8 100644 --- a/engine/app/assets/stylesheets/coplan/document-resources.css +++ b/engine/app/assets/stylesheets/coplan/document-resources.css @@ -526,9 +526,9 @@ } #new-plan-modal::backdrop { - background: rgba(4, 9, 20, 0.58); - -webkit-backdrop-filter: blur(5px); - backdrop-filter: blur(5px); + background: var(--overlay-tint, rgba(4, 9, 20, 0.58)); + -webkit-backdrop-filter: var(--overlay-blur, blur(5px)); + backdrop-filter: var(--overlay-blur, blur(5px)); } #new-plan-modal .add-modal__header { diff --git a/engine/app/assets/stylesheets/coplan/expanded-content.css b/engine/app/assets/stylesheets/coplan/expanded-content.css index d4df3685..df0881fa 100644 --- a/engine/app/assets/stylesheets/coplan/expanded-content.css +++ b/engine/app/assets/stylesheets/coplan/expanded-content.css @@ -30,8 +30,8 @@ } .expander::backdrop { - background: rgba(8, 12, 24, 0.55); - backdrop-filter: blur(2px); + background: var(--overlay-tint, rgba(8, 12, 24, 0.55)); + backdrop-filter: var(--overlay-blur, blur(2px)); } .expander__bar { diff --git a/engine/app/assets/stylesheets/coplan/foundation.css b/engine/app/assets/stylesheets/coplan/foundation.css index 02059733..8da0f405 100644 --- a/engine/app/assets/stylesheets/coplan/foundation.css +++ b/engine/app/assets/stylesheets/coplan/foundation.css @@ -422,10 +422,10 @@ body { background-color: var(--color-bg); /* The liquid-glass backdrop: three soft color spots that panels blur over. Fixed so scrolling glides content across the light. */ - background-image: + background-image: var(--page-backdrop, radial-gradient(42rem 30rem at 12% -8%, var(--mesh-1), transparent 60%), radial-gradient(38rem 28rem at 88% 10%, var(--mesh-2), transparent 60%), - radial-gradient(50rem 38rem at 45% 115%, var(--mesh-3), transparent 65%); + radial-gradient(50rem 38rem at 45% 115%, var(--mesh-3), transparent 65%)); background-attachment: fixed; color: var(--color-text); } diff --git a/engine/app/assets/stylesheets/coplan/markdown.css b/engine/app/assets/stylesheets/coplan/markdown.css index 140f421c..ef637d41 100644 --- a/engine/app/assets/stylesheets/coplan/markdown.css +++ b/engine/app/assets/stylesheets/coplan/markdown.css @@ -135,41 +135,96 @@ top: 1px; } +/* Reading typography for the document body, read or edited (the editor's + ProseMirror is a .markdown-rendered too, so writing matches reading). + Comment bodies, previews and popovers keep the compact base type above; + deck slides own theirs, and diagram labels keep the line-height Mermaid + measured (below). Theme hooks; docs/THEMES.md. */ +:is(.plan-reader, .document-editor__body) .markdown-rendered:not(.deck-content) { + max-width: var(--reading-measure, none); +} + +:is(.plan-reader, .document-editor__body) .markdown-rendered:not(.deck-content) :is(p, li):not(.mermaid-diagram *) { + line-height: var(--reading-leading, 1.9); +} + +:is(.plan-reader, .document-editor__body) .markdown-rendered:not(.deck-content) p:not(.mermaid-diagram *) { + margin-bottom: var(--reading-paragraph-gap, var(--space-md)); +} + +:is(.plan-reader, .document-editor__body) .markdown-rendered:not(.deck-content) :is(h1, h2, h3, h4, h5, h6) { + font-weight: var(--reading-heading-weight, 700); + letter-spacing: var(--reading-heading-tracking, normal); + text-wrap: var(--reading-heading-wrap, wrap); +} + +:is(.plan-reader, .document-editor__body) .markdown-rendered:not(.deck-content) h1 { + font-size: var(--reading-h1-size, var(--text-2xl)); + line-height: var(--reading-h1-leading, 1.3); +} + +:is(.plan-reader, .document-editor__body) .markdown-rendered:not(.deck-content) h2 { + font-size: var(--reading-h2-size, var(--text-xl)); + line-height: var(--reading-h2-leading, 1.3); + margin-top: var(--reading-h2-space-above, var(--space-xl)); + margin-bottom: var(--reading-h2-space-below, var(--space-md)); +} + +:is(.plan-reader, .document-editor__body) .markdown-rendered:not(.deck-content) h3 { + font-size: var(--reading-h3-size, var(--text-lg)); + line-height: var(--reading-h3-leading, 1.3); + margin-top: var(--reading-h3-space-above, var(--space-xl)); + margin-bottom: var(--reading-h3-space-below, var(--space-md)); +} + +:is(.plan-reader, .document-editor__body) .markdown-rendered:not(.deck-content) > :is(h1, h2, h3):first-child { + margin-top: 0; +} + +.markdown-rendered strong { + font-weight: var(--strong-weight, bolder); +} + .markdown-rendered pre { background: var(--code-block-bg); - border: 1px solid var(--color-border); - border-radius: var(--radius); - padding: var(--space-md); + border: var(--code-block-border, 1px solid var(--color-border)); + border-radius: var(--code-block-radius, var(--radius)); + padding: var(--code-block-padding, var(--space-md)); overflow-x: auto; margin-bottom: var(--space-md); font-family: var(--font-mono); - font-size: var(--text-sm); - line-height: 1.5; + font-size: var(--code-block-font-size, var(--text-sm)); + line-height: var(--code-block-leading, 1.5); + scrollbar-width: var(--code-block-scrollbar, auto); } /* Terminal-style header on fenced code blocks that declare a language: traffic-light dots + the language name from the lang attribute. Pure CSS (attr() in a pseudo-element) so it adds no DOM text — comment-anchor matching against textContent never sees it. Mermaid blocks are excluded; - they get replaced by rendered diagrams. */ + they get replaced by rendered diagrams. The --code-label-* hooks let a + theme reshape it, for example into a corner label (docs/THEMES.md). */ .markdown-rendered pre[lang]:not([lang="mermaid"])::before { content: attr(lang); display: block; position: sticky; /* stay pinned when the code scrolls horizontally */ left: 0; - margin: calc(-1 * var(--space-md)) calc(-1 * var(--space-md)) var(--space-md); - padding: 0.5em var(--space-md) 0.5em 4.6em; - background-color: var(--code-header-bg); - background-image: + height: var(--code-label-height, auto); + margin: var(--code-label-margin, calc(-1 * var(--space-md)) calc(-1 * var(--space-md)) var(--space-md)); + padding: var(--code-label-padding, 0.5em var(--space-md) 0.5em 4.6em); + background-color: var(--code-label-bg, var(--code-header-bg)); + background-image: var(--code-label-dots, radial-gradient(circle at 1.1em 50%, #ff5f57 0.3em, transparent 0.36em), radial-gradient(circle at 2.2em 50%, #febc2e 0.3em, transparent 0.36em), - radial-gradient(circle at 3.3em 50%, #28c840 0.3em, transparent 0.36em); - border-bottom: 1px solid var(--color-border); - border-radius: calc(var(--radius) - 1px) calc(var(--radius) - 1px) 0 0; + radial-gradient(circle at 3.3em 50%, #28c840 0.3em, transparent 0.36em)); + border-bottom: var(--code-label-border, 1px solid var(--color-border)); + border-radius: var(--code-label-radius, calc(var(--radius) - 1px) calc(var(--radius) - 1px) 0 0); color: var(--code-header-text); + font-family: var(--code-label-font, var(--font-mono)); font-size: 0.75rem; - line-height: 1.2; - letter-spacing: 0.04em; + line-height: var(--code-label-leading, 1.2); + letter-spacing: var(--code-label-tracking, 0.04em); + text-align: var(--code-label-align, match-parent); user-select: none; } @@ -180,8 +235,9 @@ .markdown-rendered :not(pre) > code { background: var(--color-code-bg); - padding: 0.15em 0.35em; - border-radius: 3px; + padding: var(--code-chip-padding, 0.15em 0.35em); + border-radius: var(--code-chip-radius, 3px); + font-size: var(--code-chip-size, 0.875em); } /* Syntax highlighting — highlight.js token classes mapped onto the @@ -258,9 +314,9 @@ .markdown-rendered .mermaid-diagram { position: relative; margin-bottom: var(--space-md); - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius); + background: var(--diagram-bg, var(--color-surface)); + border: var(--diagram-border, 1px solid var(--color-border)); + border-radius: var(--diagram-radius, var(--radius)); cursor: default; } @@ -369,7 +425,7 @@ } .markdown-rendered blockquote { - border-left: 3px solid var(--color-border); + border-left: 3px solid var(--blockquote-rule, var(--color-border)); padding-left: var(--space-md); color: var(--color-text-muted); margin-bottom: var(--space-md); @@ -401,7 +457,7 @@ position: relative; margin-bottom: var(--space-md); border: 1px solid var(--color-border); - border-radius: var(--radius); + border-radius: var(--table-radius, var(--radius)); background: var(--color-surface); } @@ -427,6 +483,7 @@ border-collapse: separate; border-spacing: 0; font-size: var(--text-sm); + font-variant-numeric: var(--table-numerals, normal); } .markdown-rendered .data-grid th, @@ -481,13 +538,13 @@ .markdown-rendered .data-grid::before { left: 1px; - border-radius: var(--radius) 0 0 var(--radius); + border-radius: var(--table-radius, var(--radius)) 0 0 var(--table-radius, var(--radius)); background: linear-gradient(to right, var(--color-surface), transparent); } .markdown-rendered .data-grid::after { right: 1px; - border-radius: 0 var(--radius) var(--radius) 0; + border-radius: 0 var(--table-radius, var(--radius)) var(--table-radius, var(--radius)) 0; background: linear-gradient(to left, var(--color-surface), transparent); } @@ -553,6 +610,8 @@ .markdown-rendered img { max-width: 100%; border-radius: var(--radius); + outline: var(--image-outline, none); + outline-offset: -1px; } /* Image editor chrome hugs the picture without adding a card or changing diff --git a/engine/app/assets/stylesheets/coplan/plan-header.css b/engine/app/assets/stylesheets/coplan/plan-header.css index e30b375e..b01b6556 100644 --- a/engine/app/assets/stylesheets/coplan/plan-header.css +++ b/engine/app/assets/stylesheets/coplan/plan-header.css @@ -118,6 +118,15 @@ overflow-wrap: anywhere; } +/* The plan title (theme hooks; docs/THEMES.md). */ +.page-header .page-header__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); + letter-spacing: var(--plan-title-tracking, normal); + text-wrap: var(--plan-title-wrap, wrap); +} + .page-header__byline { display: flex; align-items: center; diff --git a/engine/app/assets/stylesheets/coplan/presence-and-voice.css b/engine/app/assets/stylesheets/coplan/presence-and-voice.css index 8c62d1bf..a42af0c1 100644 --- a/engine/app/assets/stylesheets/coplan/presence-and-voice.css +++ b/engine/app/assets/stylesheets/coplan/presence-and-voice.css @@ -248,8 +248,8 @@ del.agent-flash { color: var(--color-text-muted, currentColor); background: var(--color-surface); border: 1px solid var(--color-border); - box-shadow: 0 2px 12px rgb(0 0 0 / 12%); - backdrop-filter: blur(8px); + box-shadow: var(--window-shadow, 0 2px 12px rgb(0 0 0 / 12%)); + backdrop-filter: var(--window-blur, blur(8px)); transition: transform 120ms ease, color 120ms ease, background 120ms ease; } diff --git a/engine/app/assets/stylesheets/coplan/responsive.css b/engine/app/assets/stylesheets/coplan/responsive.css index f2739453..ee1074ed 100644 --- a/engine/app/assets/stylesheets/coplan/responsive.css +++ b/engine/app/assets/stylesheets/coplan/responsive.css @@ -163,14 +163,18 @@ overflow-wrap: anywhere; } + .page-header .page-header__title { + font-size: var(--plan-title-size-narrow, var(--text-xl)); + } + /* At phone widths the byline can wrap under itself freely. */ .page-header__byline { flex-wrap: wrap; } - .plan-document { + .plan-document.card { /* Leave room for the update dot's full hit area inside the card. */ - padding: var(--space-lg); + padding: var(--document-padding-narrow, var(--space-lg)); } diff --git a/engine/app/assets/stylesheets/coplan/review.css b/engine/app/assets/stylesheets/coplan/review.css index e6603786..879b5041 100644 --- a/engine/app/assets/stylesheets/coplan/review.css +++ b/engine/app/assets/stylesheets/coplan/review.css @@ -9,11 +9,11 @@ max-height: calc(100dvh - var(--comment-window-top, 12px) - 12px); padding: 10px 14px 28px; border-radius: 16px; - border: 1px solid light-dark(#ffffffb3, #ffffff30); - background: linear-gradient(145deg, light-dark(#ffffffa6, #26354a80), light-dark(#f6f8fc70, #16203366)); - backdrop-filter: blur(14px) saturate(160%); - -webkit-backdrop-filter: blur(14px) saturate(160%); - box-shadow: inset 0 1px 0 #ffffff30, inset 0 -1px 0 #ffffff0a, 0 8px 30px #00000026; + border: 1px solid var(--window-edge, light-dark(#ffffffb3, #ffffff30)); + background: var(--window-bg, linear-gradient(145deg, light-dark(#ffffffa6, #26354a80), light-dark(#f6f8fc70, #16203366))); + backdrop-filter: var(--window-blur, blur(14px) saturate(160%)); + -webkit-backdrop-filter: var(--window-blur, blur(14px) saturate(160%)); + box-shadow: var(--window-shadow, inset 0 1px 0 #ffffff30, inset 0 -1px 0 #ffffff0a, 0 8px 30px #00000026); color: var(--color-text); overflow: auto; } @@ -76,7 +76,7 @@ .composer .composer__input { width: 100%; min-height: 70px; padding: 9px 10px; margin: 0; border: 1px solid var(--color-border); border-radius: 9px; - background: light-dark(#ffffff30, #07111d18); color: var(--color-text); + background: var(--composer-input-bg, light-dark(#ffffff30, #07111d18)); color: var(--color-text); font: 13px/1.55 var(--font-sans); resize: none; } .composer .composer__input:focus { outline: none; border-color: color-mix(in srgb, var(--color-primary) 55%, transparent); box-shadow: 0 0 0 2px var(--color-focus-ring); } @@ -136,10 +136,10 @@ margin: 0; padding: 0; top: auto; right: auto; position: fixed; bottom: max(16px, env(safe-area-inset-bottom)); left: 50%; transform: translateX(-50%); width: min(920px, calc(100% - 32px)); z-index: 90; - border: 1px solid light-dark(#ffffffb3, #ffffff30); border-radius: 16px; - background: linear-gradient(145deg, light-dark(#ffffffa6, #26354a80), light-dark(#f6f8fc70, #16203366)); - backdrop-filter: blur(14px) saturate(160%); -webkit-backdrop-filter: blur(14px) saturate(160%); - box-shadow: inset 0 1px 0 #ffffff30, inset 0 -1px 0 #ffffff0a, 0 8px 30px #00000026; + border: 1px solid var(--window-edge, light-dark(#ffffffb3, #ffffff30)); border-radius: 16px; + background: var(--window-bg, linear-gradient(145deg, light-dark(#ffffffa6, #26354a80), light-dark(#f6f8fc70, #16203366))); + backdrop-filter: var(--window-blur, blur(14px) saturate(160%)); -webkit-backdrop-filter: var(--window-blur, blur(14px) saturate(160%)); + box-shadow: var(--window-shadow, inset 0 1px 0 #ffffff30, inset 0 -1px 0 #ffffff0a, 0 8px 30px #00000026); color: var(--color-text); } .review-tray__bar { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 12px 16px; } @@ -160,7 +160,7 @@ background: var(--glass-bg-strong); backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur); box-shadow: var(--shadow-glass); overflow: auto; scrollbar-width: thin; } -.review-dialog::backdrop { background: light-dark(#172d4526, #00000066); backdrop-filter: blur(3px); } +.review-dialog::backdrop { background: var(--dialog-backdrop, light-dark(#172d4526, #00000066)); backdrop-filter: var(--overlay-blur, blur(3px)); } .review-dialog h2 { margin: 0 0 12px; font-size: 18px; font-weight: 500; } .review-dialog__heading h2 { margin: 0; } .review-dialog p { font-size: 13px; } @@ -187,15 +187,15 @@ /* Review log: a roster with compact controls and a readable activity trail. */ .review-management { width: min(650px, calc(100vw - 32px)); padding: 28px; border-radius: 22px; - background: linear-gradient(155deg, light-dark(#ffffffed, #1c2b43ed), light-dark(#f1f5fbed, #0e1729ed)); - box-shadow: inset 0 1px 0 #ffffff20, 0 28px 100px light-dark(#1b355b40, #00000080); + background: var(--window-bg, linear-gradient(155deg, light-dark(#ffffffed, #1c2b43ed), light-dark(#f1f5fbed, #0e1729ed))); + box-shadow: var(--window-shadow, inset 0 1px 0 #ffffff20, 0 28px 100px light-dark(#1b355b40, #00000080)); } .review-log-panel { container-type: inline-size; - padding: 22px; border: 1px solid var(--glass-edge); border-radius: 18px; - background: linear-gradient(135deg, light-dark(#ffffff80, #26354a66), light-dark(#f3f7fc66, #111c2e80)); + padding: 22px; border: 1px solid var(--panel-edge, var(--glass-edge)); border-radius: 18px; + background: var(--panel-bg, linear-gradient(135deg, light-dark(#ffffff80, #26354a66), light-dark(#f3f7fc66, #111c2e80))); backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur); - box-shadow: inset 0 1px 0 #ffffff18, 0 8px 30px #0000000c; + box-shadow: var(--panel-shadow, inset 0 1px 0 #ffffff18, 0 8px 30px #0000000c); } .review-log-panel__header { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .review-log-panel__header h2 { font-size: 16px; letter-spacing: -0.3px; font-weight: 500; margin: 0; } @@ -257,7 +257,7 @@ button.visibility-option__button:hover:not(:disabled) { background: var(--color- .review-suggestions__label { display: block; margin-bottom: 4px; font-size: 11px; font-weight: 500; color: var(--color-text-muted); } .review-suggestions__list { max-height: 180px; overflow: auto; margin: 0 -8px; } .review-search-result__avatar { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 50%; background: var(--color-interaction-hover-bg); color: var(--color-primary); font-size: 10px; flex-shrink: 0; } -.review-person { display: flex; align-items: center; gap: 12px; padding: 12px; border: 1px solid var(--color-border); border-radius: 12px; background: light-dark(#ffffff66, #ffffff03); } +.review-person { display: flex; align-items: center; gap: 12px; padding: 12px; border: 1px solid var(--color-border); border-radius: 12px; background: var(--panel-row-bg, light-dark(#ffffff66, #ffffff03)); } .review-person + .review-person { margin-top: 7px; } .review-person > .avatar { flex-shrink: 0; } .review-person__info { flex: 1; min-width: 0; } @@ -357,10 +357,10 @@ button.visibility-option__button:hover:not(:disabled) { background: var(--color- .thread-popover__permalink { position: relative; } .comment-window__copy-feedback { position: absolute; z-index: 1; top: calc(100% + 4px); right: 0; - padding: 5px 8px; border: 1px solid light-dark(#ffffffb3, #ffffff30); border-radius: 7px; - background: light-dark(#f7f9fce6, #26354ae6); color: var(--color-text); - backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); - box-shadow: 0 3px 12px #0000001a; font: 11px/1.4 var(--font-sans); + padding: 5px 8px; border: 1px solid var(--window-edge, light-dark(#ffffffb3, #ffffff30)); border-radius: 7px; + background: var(--window-bg, light-dark(#f7f9fce6, #26354ae6)); color: var(--color-text); + backdrop-filter: var(--window-blur, blur(14px)); -webkit-backdrop-filter: var(--window-blur, blur(14px)); + box-shadow: var(--window-shadow, 0 3px 12px #0000001a); font: 11px/1.4 var(--font-sans); white-space: nowrap; pointer-events: none; opacity: 0; } .thread-popover__permalink:hover .comment-window__copy-feedback, @@ -398,7 +398,7 @@ button.visibility-option__button:hover:not(:disabled) { background: var(--color- } /* Back matter reads as one surface, with the same disclosure row throughout. */ -.document-support { --document-support-inset: 16px; margin-top: var(--space-lg); border: 1px solid var(--glass-edge); border-radius: 14px; background: linear-gradient(140deg, light-dark(#ffffff55, #25364a30), light-dark(#f4f7fa55, #111c2e30)); box-shadow: inset 0 1px 0 #ffffff10; } +.document-support { --document-support-inset: 16px; margin-top: var(--space-lg); border: 1px solid var(--panel-edge, var(--glass-edge)); border-radius: var(--document-support-radius, 14px); background: var(--panel-bg, linear-gradient(140deg, light-dark(#ffffff55, #25364a30), light-dark(#f4f7fa55, #111c2e30))); box-shadow: var(--panel-shadow, inset 0 1px 0 #ffffff10); } .document-support .plan-footnotes { margin: 0; padding: 0; border: 0; gap: 0; } .document-support .plan-footnote, .document-support .detached-comments { position: relative; margin: 0; padding: 0; border: 0; border-radius: 0; background: transparent; box-shadow: none; backdrop-filter: none; } .document-support #footnote-attachments, .document-support #footnote-approvals, .document-support > .detached-comments { border-top: 1px solid var(--color-border); } diff --git a/engine/app/helpers/coplan/application_helper.rb b/engine/app/helpers/coplan/application_helper.rb index 31cc54cf..91ac08e6 100644 --- a/engine/app/helpers/coplan/application_helper.rb +++ b/engine/app/helpers/coplan/application_helper.rb @@ -2,6 +2,12 @@ module CoPlan module ApplicationHelper include CoPlan::KeyboardShortcutsHelper include MarkdownHelper + + # The app theme for this page, as (docs/THEMES.md). + def coplan_ui_theme + CoPlan::Themes.default + end + def coplan_favicon_tag safe_join([ tag.link(rel: "icon", type: "image/png", sizes: "192x192", href: asset_path(coplan_logo_asset(format: :png))), diff --git a/engine/app/javascript/controllers/coplan/mermaid_controller.js b/engine/app/javascript/controllers/coplan/mermaid_controller.js index 0b938c17..5f727466 100644 --- a/engine/app/javascript/controllers/coplan/mermaid_controller.js +++ b/engine/app/javascript/controllers/coplan/mermaid_controller.js @@ -55,7 +55,7 @@ export default class extends Controller { for (const { container, source } of sources) { // Sequential renders, so re-configuring per diagram is safe (and // a no-op when the theme hasn't changed). - const theme = configureMermaid(mermaid, diagramDark(container)) + const theme = configureMermaid(mermaid, diagramDark(container), Boolean(container.closest(".deck"))) await this.renderDiagram(mermaid, container, source, theme, generation) } } catch { @@ -363,8 +363,12 @@ function diagramDark(container) { (!explicitTheme && window.matchMedia("(prefers-color-scheme: dark)").matches) } -function configureMermaid(mermaid, dark) { - const theme = dark ? "dark" : "light" +// An app theme can bring its own diagram palette as CSS variables +// (docs/THEMES.md). Decks always keep the built-in themes. +function configureMermaid(mermaid, dark, deck = false) { + const palette = deck ? null : themeDiagramPalette() + const scheme = dark ? "dark" : "light" + const theme = palette ? `${palette.name}-${scheme}` : scheme if (theme === configuredTheme) return theme const fontFamily = appFontStack() @@ -372,11 +376,11 @@ function configureMermaid(mermaid, dark) { startOnLoad: false, securityLevel: "strict", suppressErrorRendering: true, - theme: dark ? "base" : "default", + theme: palette || dark ? "base" : "default", // Mermaid's 14px default reads small next to 16px body copy, and it's // the label size that survives (or doesn't) being scaled to fit. fontFamily, - themeVariables: { fontSize: "16px", fontFamily, ...(dark && darkThemeVariables()) }, + themeVariables: { fontSize: "16px", fontFamily, ...(palette ? paletteThemeVariables(palette, dark) : dark && darkThemeVariables()) }, themeCSS: THEME_CSS, flowchart: { padding: 14, nodeSpacing: 55, rankSpacing: 60, useMaxWidth: true }, sequence: { actorFontSize: 15, messageFontSize: 15, noteFontSize: 14 }, @@ -399,6 +403,65 @@ function appFontStack() { return declared || "system-ui, sans-serif" } +// Mermaid's color math needs hex, so themes give these as hex values. +const DIAGRAM_PALETTE_PROPERTIES = { + background: "--diagram-bg", + node: "--diagram-node", + nodeBorder: "--diagram-node-border", + text: "--diagram-text", + line: "--diagram-line", + cluster: "--diagram-cluster", + clusterBorder: "--diagram-cluster-border", + actorLine: "--diagram-actor-line", + alt: "--diagram-alt", + note: "--diagram-note", + noteBorder: "--diagram-note-border", + noteText: "--diagram-note-text" +} + +// The active theme's palette for the current scheme, or null when the theme +// doesn't define a complete one. +function themeDiagramPalette() { + const style = getComputedStyle(document.documentElement) + const palette = {} + for (const [key, property] of Object.entries(DIAGRAM_PALETTE_PROPERTIES)) { + palette[key] = style.getPropertyValue(property).trim() + if (!palette[key]) return null + } + palette.name = document.documentElement.dataset.uiTheme || "theme" + return palette +} + +function paletteThemeVariables(c, dark) { + // Alternate the node and alt colors so mindmap branches and pie slices + // stay distinct without introducing new hues. + const categories = Object.fromEntries(Array.from({ length: 12 }, (_, index) => + [`cScale${index}`, index % 2 ? c.alt : c.node])) + return { + ...categories, + darkMode: dark, + background: c.background, + primaryColor: c.node, primaryBorderColor: c.nodeBorder, primaryTextColor: c.text, + secondaryColor: c.alt, secondaryBorderColor: c.nodeBorder, secondaryTextColor: c.text, + tertiaryColor: c.cluster, tertiaryBorderColor: c.clusterBorder, tertiaryTextColor: c.text, + mainBkg: c.node, nodeBorder: c.nodeBorder, nodeTextColor: c.text, + textColor: c.text, titleColor: c.text, labelColor: c.text, + lineColor: c.line, defaultLinkColor: c.line, + clusterBkg: c.cluster, clusterBorder: c.clusterBorder, + edgeLabelBackground: c.background, + actorBkg: c.node, actorBorder: c.nodeBorder, actorTextColor: c.text, actorLineColor: c.actorLine, + signalColor: c.line, signalTextColor: c.text, + labelBoxBkgColor: c.node, labelBoxBorderColor: c.nodeBorder, labelTextColor: c.text, loopTextColor: c.text, + activationBkgColor: c.alt, activationBorderColor: c.nodeBorder, + noteBkgColor: c.note, noteBorderColor: c.noteBorder, noteTextColor: c.noteText, + sequenceNumberColor: c.background, + sectionBkgColor: c.alt, altSectionBkgColor: c.background, sectionBkgColor2: c.node, + taskBkgColor: c.node, taskBorderColor: c.nodeBorder, taskTextColor: c.text, + doneTaskBkgColor: c.alt, doneTaskBorderColor: c.nodeBorder, gridColor: c.actorLine, + altBackground: c.alt + } +} + function darkThemeVariables() { // The base theme darkens its derived category palette to black in dark // mode. Explicit colors keep mindmap branches visible on our dark canvas. diff --git a/engine/app/models/coplan/themes.rb b/engine/app/models/coplan/themes.rb new file mode 100644 index 00000000..100c50c6 --- /dev/null +++ b/engine/app/models/coplan/themes.rb @@ -0,0 +1,25 @@ +module CoPlan + # App themes. A theme is a named set of CSS variable values, applied with + # (docs/THEMES.md). Classic is the built-in look + # and sets no variables. Light and dark are a separate choice, so every + # theme comes in both. + module Themes + Theme = Data.define(:key, :name, :description) + + CLASSIC = "classic" + + ALL = [ + Theme.new(key: CLASSIC, name: "Classic", description: "Lexend, glass panels and the document card.") + ].freeze + + def self.keys = ALL.map(&:key) + + def self.find(key) = ALL.find { |theme| theme.key == key.to_s } + + # The host's configured default. Falls back to Classic when the host + # names a theme that doesn't exist. + def self.default + find(CoPlan.configuration.default_theme)&.key || CLASSIC + end + end +end diff --git a/engine/app/views/layouts/coplan/application.html.erb b/engine/app/views/layouts/coplan/application.html.erb index fa250d86..bf8b2a59 100644 --- a/engine/app/views/layouts/coplan/application.html.erb +++ b/engine/app/views/layouts/coplan/application.html.erb @@ -1,5 +1,5 @@ - data-theme="<%= current_user.theme_preference %>"<% end %>> + data-theme="<%= current_user.theme_preference %>"<% end %>> <%= content_for(:title) || "CoPlan" %> diff --git a/engine/lib/coplan/configuration.rb b/engine/lib/coplan/configuration.rb index 176339be..c19631da 100644 --- a/engine/lib/coplan/configuration.rb +++ b/engine/lib/coplan/configuration.rb @@ -154,10 +154,18 @@ class Configuration # config.reserved_handles = %w[square block official] attr_accessor :reserved_handles + # The app theme everyone sees unless they pick another in Settings. One + # of CoPlan::Themes.keys; unknown names fall back to "classic". + # + # Example: + # config.default_theme = "classic" + attr_accessor :default_theme + def initialize @authenticate = nil @reserved_handles = [] + @default_theme = "classic" @ai_base_url = "https://api.openai.com/v1" @ai_api_key = nil @ai_model = "gpt-4o" diff --git a/spec/models/themes_spec.rb b/spec/models/themes_spec.rb new file mode 100644 index 00000000..039fe522 --- /dev/null +++ b/spec/models/themes_spec.rb @@ -0,0 +1,35 @@ +require "rails_helper" + +RSpec.describe CoPlan::Themes do + around do |example| + original = CoPlan.configuration.default_theme + example.run + ensure + CoPlan.configuration.default_theme = original + end + + it "ships Classic as the default theme" do + expect(described_class.keys).to include("classic") + expect(CoPlan.configuration.default_theme).to eq("classic") + expect(described_class.default).to eq("classic") + end + + it "uses the host's configured default" do + CoPlan.configuration.default_theme = :classic + expect(described_class.default).to eq("classic") + end + + it "falls back to Classic for an unknown theme" do + CoPlan.configuration.default_theme = "hotdog-stand" + expect(described_class.default).to eq("classic") + expect(described_class.find("hotdog-stand")).to be_nil + end + + it "has a stylesheet for every theme except Classic" do + manifest = CoPlan::Engine.root.join("app/assets/stylesheets/coplan/application.css").read + (described_class.keys - [ "classic" ]).each do |key| + expect(CoPlan::Engine.root.join("app/assets/stylesheets/coplan/themes/#{key}.css")).to exist + expect(manifest).to include(%(@import url("./themes/#{key}.css");)) + end + end +end diff --git a/spec/requests/settings_spec.rb b/spec/requests/settings_spec.rb index 2642e1ec..66dbb67f 100644 --- a/spec/requests/settings_spec.rb +++ b/spec/requests/settings_spec.rb @@ -5,6 +5,26 @@ before { sign_in_as(alice) } + describe "app theme" do + around do |example| + original = CoPlan.configuration.default_theme + example.run + ensure + CoPlan.configuration.default_theme = original + end + + it "renders the host's default theme on " do + get settings_root_path + expect(response.parsed_body.at_css("html")["data-ui-theme"]).to eq("classic") + end + + it "falls back to Classic when the host names an unknown theme" do + CoPlan.configuration.default_theme = "hotdog-stand" + get settings_root_path + expect(response.parsed_body.at_css("html")["data-ui-theme"]).to eq("classic") + end + end + describe "PATCH /settings/theme" do it "persists an allowed theme preference" do patch settings_theme_path, params: { theme: "dark" } From 47d2155097addb0d9ee6ccfc25c46cab92370b1c Mon Sep 17 00:00:00 2001 From: Salman Mohammed Date: Fri, 30 Oct 2026 13:59:45 -0400 Subject: [PATCH 3/5] Call light and dark a mode, and name the attribute data-app-theme Light, dark and system are now a "mode" in docs, comments and the contributor checklists, so "theme" means only the app theme. Slide looks are always a "deck theme". THEMES.md gains a short glossary. The persisted and host-visible mode names (data-theme, theme_preference, theme_controller.js, coplan:theme-changed) stay, with comments at their definitions. The new attribute is renamed from data-ui-theme to data-app-theme before it ships. Co-Authored-By: Claude Opus 5.5 --- .github/PULL_REQUEST_TEMPLATE.md | 4 ++-- AGENTS.md | 4 ++-- CONTRIBUTING.md | 4 ++-- docs/DEMO_LIBRARY.md | 4 ++-- docs/DESIGN.md | 2 +- docs/HOST_APP_GUIDE.md | 10 ++++----- docs/SOURCE_COMMENTS.md | 2 +- docs/THEMES.md | 21 ++++++++++++++----- docs/human-document-editor.md | 4 ++-- .../assets/stylesheets/coplan/controls.css | 4 ++-- engine/app/assets/stylesheets/coplan/deck.css | 14 ++++++------- .../assets/stylesheets/coplan/foundation.css | 4 ++-- .../app/assets/stylesheets/coplan/welcome.css | 2 +- .../coplan/settings/settings_controller.rb | 1 + .../app/helpers/coplan/application_helper.rb | 4 ++-- .../controllers/coplan/mermaid_controller.js | 2 +- .../controllers/coplan/theme_controller.js | 2 ++ engine/app/models/coplan/themes.rb | 2 +- engine/app/models/coplan/user.rb | 2 ++ .../guides/presentations.text.erb | 2 +- .../views/layouts/coplan/application.html.erb | 2 +- spec/requests/settings_spec.rb | 4 ++-- spec/system/changed_sections_ux_spec.rb | 2 +- spec/system/editor_template_notes_spec.rb | 2 +- spec/system/human_editing_spec.rb | 2 +- spec/system/plan_reviews_spec.rb | 2 +- 26 files changed, 62 insertions(+), 46 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 67adc73c..fb1477dc 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,7 +4,7 @@ ## Evidence - + ## How to try it @@ -17,5 +17,5 @@ - [ ] Added or updated real JavaScript-enabled browser specs in `spec/system/` for changed UI behavior, covering the user interaction and visible result; or explained why no browser UI is involved. - [ ] Added model, service, or request specs where needed for domain behavior, authorization, or API cases. - [ ] Ran `bundle exec rspec`, or explained why it could not run and listed the specs that did run. -- [ ] Tested affected UI behavior in both light and dark themes, or explained why theme testing does not apply. +- [ ] Tested affected UI behavior in both light and dark mode, or explained why mode testing does not apply. - [ ] Attached a video or images demonstrating the change, if this PR adds or changes a feature or fixes a UI bug. diff --git a/AGENTS.md b/AGENTS.md index 735da8d6..a358b726 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,7 +95,7 @@ Follow [the design guidance](docs/DESIGN.md) across document, rich preview, expanded table/diagram, and editing surfaces: - **Blue means interaction:** subtle blue for hover, blue for keyboard focus, - selection, active discussions, and comment-composer quotes, in both themes. + selection, active discussions, and comment-composer quotes, in both modes. - **Selection supports reading first:** clicking table cells or selecting text must not open a composer. Offer Comment and the C shortcut. Intentional comment actions focus the comment or reply field immediately. Diagrams browse by @@ -131,7 +131,7 @@ a way for the person who requested it to try the changed behavior: - Run the relevant automated specs. Exercise UI behavior in a real browser, using a built-in browser when the agent environment provides one. For UI - changes, check light and dark themes and capture screenshots or a short + changes, check light and dark mode and capture screenshots or a short recording of the result. For visual bugs, include before-and-after evidence when useful. - Start the development server (`bin/dev`; use `bin/setup --skip-server` and diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3a8a0415..08d22e98 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,8 +8,8 @@ Thanks for helping improve CoPlan. Keep pull requests focused and make it easy f - Add **real browser specs** for new or changed UI behavior in `spec/system/`. These are RSpec system specs using Capybara and Selenium with headless Chrome, so the page's JavaScript runs. Drive the feature through the UI as a user would and assert the visible result and, where relevant, the persisted change. Cover meaningful success, failure, and edge cases. A request spec, mocked JavaScript call, or assertion that an element merely exists does not replace a browser spec for an interactive feature. - Add model, service, or request specs where they help cover domain behavior, authorization, and API cases. Keep specs focused on behavior rather than implementation details. For a feature without a browser UI, use the appropriate non-browser specs and explain why a browser spec does not apply. - Run the relevant browser specs with `bundle exec rspec spec/system/_spec.rb`, then run the full suite with `bundle exec rspec`. If you cannot run it, say why and identify exactly what you did run. -- For every feature, test the affected UI in both **light and dark themes**. Check the complete interaction, including loading, success, error, and empty states that apply. For a feature without a UI, explain in the PR why theme testing does not apply. -- Include **a video or images** in feature PRs and UI bug fixes so reviewers can see the result. Show both themes for UI changes; a short video or labeled screenshots are both fine. For features without a UI, include images of the relevant API or command output, or a short video demonstrating it. Include before-and-after evidence for visual fixes when useful. +- For every feature, test the affected UI in both **light and dark mode**. Check the complete interaction, including loading, success, error, and empty states that apply. For a feature without a UI, explain in the PR why mode testing does not apply. +- Include **a video or images** in feature PRs and UI bug fixes so reviewers can see the result. Show both modes for UI changes; a short video or labeled screenshots are both fine. For features without a UI, include images of the relevant API or command output, or a short video demonstrating it. Include before-and-after evidence for visual fixes when useful. ## Shared development examples diff --git a/docs/DEMO_LIBRARY.md b/docs/DEMO_LIBRARY.md index 3f553d25..83e59b47 100644 --- a/docs/DEMO_LIBRARY.md +++ b/docs/DEMO_LIBRARY.md @@ -55,7 +55,7 @@ shipped types, including Scratchpad, PRD, Test Plan, and Handoff. | reference-gallery | All five reference types, a resolved CoPlan target, explicit/extracted references, named citations, repeated URLs, footnotes | | table-gallery | Precise cell discussion, repeated labels, empty values, links, wide expandable data | | formatting-gallery | Headings, editable template notes hidden while reading, emphasis, quotes, nested lists, tasks, two code languages, image, mention, footnote | -| reading-specimen | A realistic design doc for judging reading in both themes: prose, headings, lists, tasks, quote, table, footnote, image, SQL/Ruby/diff/YAML code, a long scrolling line, flowchart and sequence diagrams, plus open, agent-answered, resolved, diagram-node, table-cell, and general discussions | +| reading-specimen | A realistic design doc for judging reading in both modes: prose, headings, lists, tasks, quote, table, footnote, image, SQL/Ruby/diff/YAML code, a long scrolling line, flowchart and sequence diagrams, plus open, agent-answered, resolved, diagram-node, table-cell, and general discussions | | image-resizing | Resize and restore image widths, wrap prose from the top, Shift+Enter beside, Enter below | Flowcharts currently support source-backed node and edge comments. Other @@ -204,7 +204,7 @@ can open that example from the demo index and try the new behavior. 3. Seed the real relationships or versions needed by the feature. 4. Add assertions for the initial state, repeatability, and preservation of local changes in `spec/lib/development_seed_spec.rb`. -5. Run the seed and relevant specs, then try the example in both themes. +5. Run the seed and relevant specs, then try the example in both modes. The browsable index includes every definition and refreshes as examples are added. If you edit the index yourself, re-seeding preserves your version. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 1e18a2d3..fc65f155 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2,7 +2,7 @@ Reading is the primary activity. Apply these rules consistently to document text, tables, diagrams, rich previews, expanded views, and editors, in light -and dark themes. +and dark mode. Selecting a table cell is a browsing action. Keep its composer closed until the reader presses C or Enter, chooses Comment, or opens a discussion badge. diff --git a/docs/HOST_APP_GUIDE.md b/docs/HOST_APP_GUIDE.md index 319691fc..9d8e7543 100644 --- a/docs/HOST_APP_GUIDE.md +++ b/docs/HOST_APP_GUIDE.md @@ -6,8 +6,8 @@ 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. Theme 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. Light/dark mode and +push-to-talk preferences live in `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. @@ -19,7 +19,7 @@ bin/rails co_plan:install:migrations bin/rails db:migrate ``` -`MoveUserSettingsOutOfMetadata` copies valid existing theme and hotkey values, +`MoveUserSettingsOutOfMetadata` copies valid existing mode and hotkey values, including explicit System and Off choices. Missing or invalid values use System and Ctrl+Space. Preferences erased by an earlier directory sync cannot be recovered. Rollback copies the latest settings back into metadata while preserving host fields. @@ -65,7 +65,7 @@ config.exceptions_app = CoPlan::ExceptionsApp.new(Rails.root.join("public")) ``` This page uses the regular application layout, including the signed-in viewer's -navigation and theme. Visitors without a session can still use it. Other error statuses and +navigation, mode and theme. Visitors without a session can still use it. Other error statuses and non-HTML errors retain Rails' public error handling. Development still shows Rails' detailed routing exceptions; use the preview URL to try the design. Keep a static `public/404.html` as a fallback for web servers serving errors @@ -217,7 +217,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 | -| `theme_preference` | String | Engine-owned theme: system, light, or dark | +| `theme_preference` | String | Engine-owned light/dark mode: system, light, or dark | | `voice_hotkey` | String | Engine-owned push-to-talk preference | `CoPlan::User` is a normal ActiveRecord model. Host apps can reference it directly: diff --git a/docs/SOURCE_COMMENTS.md b/docs/SOURCE_COMMENTS.md index 49db356d..9c43c5f6 100644 --- a/docs/SOURCE_COMMENTS.md +++ b/docs/SOURCE_COMMENTS.md @@ -37,7 +37,7 @@ checks a 120-row, 24-column table and commenting on its far corner. Diagram rendering coverage is distinct from the source-comment syntax described below. Hover, keyboard focus, active selections, and new-comment context use blue -interaction styling in both themes. Open discussions keep small muted badges. +interaction styling in both modes. Open discussions keep small muted badges. Unresolved does not mean unread: orange is reserved for a viewer's outstanding task or review attention, as defined in [the design guidance](DESIGN.md). diff --git a/docs/THEMES.md b/docs/THEMES.md index b7187fbd..054d3aa9 100644 --- a/docs/THEMES.md +++ b/docs/THEMES.md @@ -4,6 +4,17 @@ A theme changes how CoPlan looks, never what it does. It is a named set of CSS variable values. Light and dark mode are a separate choice, so a theme defines both. +## Terms + +| 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` | +| 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 +accept both for a release, since host CSS may target it. + Classic is the built-in look: Lexend, glass panels and the document card. It 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 @@ -11,7 +22,7 @@ only the hooks it changes. ## How a theme is applied -The layout renders ``. The value is the host's +The layout renders ``. The value is the host's `config.default_theme` (see [HOST_APP_GUIDE.md](HOST_APP_GUIDE.md)), and falls back to `classic` for an unknown name. Light and dark stay on `data-theme`. @@ -24,12 +35,12 @@ 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-ui-theme=""]`. For dark mode, - repeat them under `:root[data-ui-theme=""][data-theme="dark"]` and +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-ui-theme=""]:not([data-theme])`. + `: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-ui-theme=""] .plan-document { --color-surface: … }`. + example `:root[data-app-theme=""] .plan-document { --color-surface: … }`. 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. diff --git a/docs/human-document-editor.md b/docs/human-document-editor.md index e502425c..792290d3 100644 --- a/docs/human-document-editor.md +++ b/docs/human-document-editor.md @@ -18,7 +18,7 @@ unique index. Rich mode shares reading-view typography. The compact toolbar provides selected states, accessible labels and shortcut hints. The link button uses the same Lucide outline convention as the app; style controls and native options follow -light/dark themes. Cmd/Ctrl+B/I format, Cmd/Ctrl+Z undoes, Cmd/Ctrl+Shift+Z redoes, +light and dark mode. Cmd/Ctrl+B/I format, Cmd/Ctrl+Z undoes, Cmd/Ctrl+Shift+Z redoes, and Cmd/Ctrl+K adds a link. Lists support Enter, Tab and Shift+Tab. At the start of a paragraph, typing `* ` creates a bullet. Immediate Backspace restores the literal asterisk and space. After typing an item and pressing Enter, @@ -289,7 +289,7 @@ large structural rewrites can move the selection to the changed range. Run `bundle exec rspec`. Concurrent worktrees need distinct test databases via `DATABASE_URL`, so one suite cannot reset another suite’s schema or data. `spec/system/editor_lists_spec.rb` covers bullet typing, immediate reversal, -empty-item exit and caret alignment in both themes, repeated Backspace without +empty-item exit and caret alignment in both modes, repeated Backspace without new bullets, blank lines after lists, citation metadata, and saved/reopened lists. `spec/system/document_editor_modes_spec.rb` covers mode fidelity, table/Mermaid previews, Close editor success/error/conflict/in-flight behavior, diff --git a/engine/app/assets/stylesheets/coplan/controls.css b/engine/app/assets/stylesheets/coplan/controls.css index 06134f1e..163cdd9d 100644 --- a/engine/app/assets/stylesheets/coplan/controls.css +++ b/engine/app/assets/stylesheets/coplan/controls.css @@ -181,7 +181,7 @@ .plan-type-icon--lg { width: 36px; height: 36px; border-radius: 9px; } .plan-type-icon--lg svg { width: 19px; height: 19px; } -/* Saturated mid-tones read on both themes (like language dots). */ +/* Saturated mid-tones read in both modes (like language dots). */ .plan-type-icon--0 { background: color-mix(in srgb, #5b8def 16%, transparent); color: #5b8def; } .plan-type-icon--1 { background: color-mix(in srgb, #3fa876 16%, transparent); color: #3fa876; } .plan-type-icon--2 { background: color-mix(in srgb, #d99a2b 16%, transparent); color: #d99a2b; } @@ -206,7 +206,7 @@ .avatar--xl { width: 4rem; height: 4rem; font-size: 1.3rem; } .avatar--initials { - /* White initials need a deeper blue than the dark theme's link color. */ + /* White initials need a deeper blue than dark mode's link color. */ background: #2563eb; color: var(--color-text-inverse); font-weight: 600; diff --git a/engine/app/assets/stylesheets/coplan/deck.css b/engine/app/assets/stylesheets/coplan/deck.css index 99cf2c56..2fc95f19 100644 --- a/engine/app/assets/stylesheets/coplan/deck.css +++ b/engine/app/assets/stylesheets/coplan/deck.css @@ -20,8 +20,8 @@ display: flex; flex-direction: column; gap: 2rem; - /* A deck is a fixed artifact: app themes don't restyle it. Setting a - theme hook to initial brings back its built-in value (docs/THEMES.md). + /* A deck is a fixed artifact: app themes don't restyle it. Setting an + app theme hook to initial brings back its built-in value (docs/THEMES.md). Code keeps the app's original mono stack. */ --font-mono: "Hack", "SF Mono", SFMono-Regular, ui-monospace, Menlo, monospace; --strong-weight: initial; @@ -151,7 +151,7 @@ light/dark mode — inside a deck that painted deck-ink text on a near-black panel — so the panel pins every variable the host's code chrome reads (panel, header, hairlines, highlight tokens) and resets - the code-block theme hooks. */ + the code-block app theme hooks. */ .deck-slide .deck-content pre { font-size: 0.8em; line-height: 1.55; @@ -573,7 +573,7 @@ put the room's eyes on it (the presenter swallows the drag so it doesn't also page forward, and starves the comment affordance). Inverse video in the deck's own accent, not the OS selection tint — this has to read from - the back of a room, on a projector, in every theme. Title slides invert + the back of a room, on a projector, in every deck theme. Title slides invert against their own canvas instead: poster's title background IS its accent, so an accent block would vanish there. */ .deck--presenting .deck-slide::selection, @@ -607,7 +607,7 @@ .deck-ink__stroke { fill: none; - /* Themes registered before the pen existed still get a visible mark. */ + /* Deck themes registered before the pen existed still get a visible mark. */ stroke: var(--deck-pen, var(--deck-accent)); stroke-linecap: round; stroke-linejoin: round; @@ -616,7 +616,7 @@ filter: drop-shadow(0 0.15cqi 0.3cqi rgb(0 0 0 / 0.35)); } -/* A theme may art-direct its title canvas into the pen's own color +/* A deck theme may art-direct its title canvas into the pen's own color (poster's title slide IS the accent), so the pen gets the same title-slide escape hatch the accent does. :has() rather than inheritance: the layer belongs to the canvas, not to a slide. */ @@ -678,7 +678,7 @@ } /* The badge's shell follows the canvas it sits on, and only the glyph is - the pen. A theme's --deck-muted is a color for its body canvas: on an + the pen. A deck theme's --deck-muted is a color for its body canvas: on an art-directed title canvas the hint has to come from that canvas's own ink instead, or it disappears into the background. */ .deck--presenting:has(.deck-slide--current.deck-slide--title) .deck-ink-badge { diff --git a/engine/app/assets/stylesheets/coplan/foundation.css b/engine/app/assets/stylesheets/coplan/foundation.css index 8da0f405..37e42f13 100644 --- a/engine/app/assets/stylesheets/coplan/foundation.css +++ b/engine/app/assets/stylesheets/coplan/foundation.css @@ -297,7 +297,7 @@ body[data-writing-inspection] :is(#plan-toolbar a, .site-nav__edit) { opacity: . } } -/* Forced dark theme — overrides system preference */ +/* Forced dark mode — overrides system preference */ :root[data-theme="dark"] { color-scheme: dark; --color-bg: #0b1220; @@ -389,7 +389,7 @@ body[data-writing-inspection] :is(#plan-toolbar a, .site-nav__edit) { opacity: . --card-edge: rgba(148, 163, 184, 0.16); } -/* Forced light theme — overrides system dark preference */ +/* Forced light mode — overrides system dark preference */ :root[data-theme="light"] { color-scheme: light; } diff --git a/engine/app/assets/stylesheets/coplan/welcome.css b/engine/app/assets/stylesheets/coplan/welcome.css index 623ee304..84af1bfc 100644 --- a/engine/app/assets/stylesheets/coplan/welcome.css +++ b/engine/app/assets/stylesheets/coplan/welcome.css @@ -189,7 +189,7 @@ /* Landing mock — an illustrative, CSS-only rendering of the review loop (anchored comment → agent edit → new version). Uses the same design tokens - as the real plan UI so it stays truthful in both light and dark themes. */ + as the real plan UI so it stays truthful in both light and dark mode. */ .landing-mock { display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); diff --git a/engine/app/controllers/coplan/settings/settings_controller.rb b/engine/app/controllers/coplan/settings/settings_controller.rb index 88a21842..14d1049f 100644 --- a/engine/app/controllers/coplan/settings/settings_controller.rb +++ b/engine/app/controllers/coplan/settings/settings_controller.rb @@ -7,6 +7,7 @@ def index @web_push_subscriptions = current_user.web_push_subscriptions.order(created_at: :desc) end + # Saves the light/dark mode (docs/THEMES.md). def update_theme theme = params[:theme] if CoPlan::User::THEME_PREFERENCES.include?(theme) diff --git a/engine/app/helpers/coplan/application_helper.rb b/engine/app/helpers/coplan/application_helper.rb index 91ac08e6..99fb1fcd 100644 --- a/engine/app/helpers/coplan/application_helper.rb +++ b/engine/app/helpers/coplan/application_helper.rb @@ -3,8 +3,8 @@ module ApplicationHelper include CoPlan::KeyboardShortcutsHelper include MarkdownHelper - # The app theme for this page, as (docs/THEMES.md). - def coplan_ui_theme + # The app theme for this page, as (docs/THEMES.md). + def coplan_app_theme CoPlan::Themes.default end diff --git a/engine/app/javascript/controllers/coplan/mermaid_controller.js b/engine/app/javascript/controllers/coplan/mermaid_controller.js index 5f727466..8a4e9e08 100644 --- a/engine/app/javascript/controllers/coplan/mermaid_controller.js +++ b/engine/app/javascript/controllers/coplan/mermaid_controller.js @@ -428,7 +428,7 @@ function themeDiagramPalette() { palette[key] = style.getPropertyValue(property).trim() if (!palette[key]) return null } - palette.name = document.documentElement.dataset.uiTheme || "theme" + palette.name = document.documentElement.dataset.appTheme || "theme" return palette } diff --git a/engine/app/javascript/controllers/coplan/theme_controller.js b/engine/app/javascript/controllers/coplan/theme_controller.js index 6f8e7794..26c6c3a8 100644 --- a/engine/app/javascript/controllers/coplan/theme_controller.js +++ b/engine/app/javascript/controllers/coplan/theme_controller.js @@ -1,5 +1,7 @@ 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. export default class extends Controller { static values = { url: String } diff --git a/engine/app/models/coplan/themes.rb b/engine/app/models/coplan/themes.rb index 100c50c6..d8367b20 100644 --- a/engine/app/models/coplan/themes.rb +++ b/engine/app/models/coplan/themes.rb @@ -1,6 +1,6 @@ module CoPlan # App themes. A theme is a named set of CSS variable values, applied with - # (docs/THEMES.md). Classic is the built-in look + # (docs/THEMES.md). Classic is the built-in look # and sets no variables. Light and dark are a separate choice, so every # theme comes in both. module Themes diff --git a/engine/app/models/coplan/user.rb b/engine/app/models/coplan/user.rb index 270cbb92..042aba8e 100644 --- a/engine/app/models/coplan/user.rb +++ b/engine/app/models/coplan/user.rb @@ -1,5 +1,7 @@ module CoPlan class User < ApplicationRecord + # Light/dark mode, not an app theme (docs/THEMES.md). The "theme" names + # predate app themes and are persisted, so they stay. THEME_PREFERENCES = %w[system light dark].freeze # Which key holds the microphone open for push-to-talk commenting. diff --git a/engine/app/views/coplan/agent_instructions/guides/presentations.text.erb b/engine/app/views/coplan/agent_instructions/guides/presentations.text.erb index 559b4a29..fc3402e2 100644 --- a/engine/app/views/coplan/agent_instructions/guides/presentations.text.erb +++ b/engine/app/views/coplan/agent_instructions/guides/presentations.text.erb @@ -29,7 +29,7 @@ Analysis after the deck. Another deck can follow later in the plan. - The opening line and the closing `:::` must each be a line by itself, with a blank line before and after. - `#q3-review` is an optional ID: a letter, then letters, digits, `_`, or `-`. Use one when people link to the deck. -- `theme` is optional: `coplan` (the default) or `graphite`. +- `theme` is the optional deck theme: `coplan` (the default) or `graphite`. - CoPlan shows a region marker that it does not recognize, or a region that is not closed, as plain text. If the deck does not appear, check the marker lines. - The `Presentation` plan type's template starts with a deck region. Use that type when the whole plan is a deck. To add a deck to another plan, add a region. diff --git a/engine/app/views/layouts/coplan/application.html.erb b/engine/app/views/layouts/coplan/application.html.erb index bf8b2a59..e7964582 100644 --- a/engine/app/views/layouts/coplan/application.html.erb +++ b/engine/app/views/layouts/coplan/application.html.erb @@ -1,5 +1,5 @@ - data-theme="<%= current_user.theme_preference %>"<% end %>> + data-theme="<%= current_user.theme_preference %>"<% end %>> <%= content_for(:title) || "CoPlan" %> diff --git a/spec/requests/settings_spec.rb b/spec/requests/settings_spec.rb index 66dbb67f..8ad2317b 100644 --- a/spec/requests/settings_spec.rb +++ b/spec/requests/settings_spec.rb @@ -15,13 +15,13 @@ it "renders the host's default theme on " do get settings_root_path - expect(response.parsed_body.at_css("html")["data-ui-theme"]).to eq("classic") + expect(response.parsed_body.at_css("html")["data-app-theme"]).to eq("classic") end it "falls back to Classic when the host names an unknown theme" do CoPlan.configuration.default_theme = "hotdog-stand" get settings_root_path - expect(response.parsed_body.at_css("html")["data-ui-theme"]).to eq("classic") + expect(response.parsed_body.at_css("html")["data-app-theme"]).to eq("classic") end end diff --git a/spec/system/changed_sections_ux_spec.rb b/spec/system/changed_sections_ux_spec.rb index 63f7743b..f4a88cdc 100644 --- a/spec/system/changed_sections_ux_spec.rb +++ b/spec/system/changed_sections_ux_spec.rb @@ -127,7 +127,7 @@ def broadcast_section_update(keys:, revision: 3, rewritten: false, rename: false expect(page).to have_no_css(".changed-sections-note", visible: :all) end - it "marks only section starts and keeps rich content's own surfaces in both themes" do + it "marks only section starts and keeps rich content's own surfaces in both modes" do %w[light dark].each do |theme| page.execute_script("document.documentElement.dataset.theme = '#{theme}'") expect(page).to have_no_css(".changed-sections-note", visible: :all) diff --git a/spec/system/editor_template_notes_spec.rb b/spec/system/editor_template_notes_spec.rb index e11ce2b0..69a9fa99 100644 --- a/spec/system/editor_template_notes_spec.rb +++ b/spec/system/editor_template_notes_spec.rb @@ -81,7 +81,7 @@ page.save_screenshot(Rails.root.join("tmp", "template-notes-reading.png")) end - it "edits notes in place with multiline input, undo, autosave and muted text in both themes" do + it "edits notes in place with multiline input, undo, autosave and muted text in both modes" do %w[light dark].each do |theme| page.execute_script("document.documentElement.dataset.theme = arguments[0]", theme) colors = page.evaluate_script(<<~'JS') diff --git a/spec/system/human_editing_spec.rb b/spec/system/human_editing_spec.rb index 00eec5fb..b906548c 100644 --- a/spec/system/human_editing_spec.rb +++ b/spec/system/human_editing_spec.rb @@ -852,7 +852,7 @@ def save_now expect(page).to have_no_css("#plan-citations", text: "::: {.iframe") end - it "opens back-matter citations for whole content blocks in both themes" do + it "opens back-matter citations for whole content blocks in both modes" do source = "::: {.presentation}\n\n# Review\n\nDecision.[^catalog]\n\n:::\n\n" + '::: {.iframe src="https://unapproved.example.com/report" title="Report" /}' + "\n\n[^catalog]: Catalog details.\n" CoPlan::Plans::ReplaceContent.call(plan: plan, new_content: source, diff --git a/spec/system/plan_reviews_spec.rb b/spec/system/plan_reviews_spec.rb index 47fa5804..b529b4eb 100644 --- a/spec/system/plan_reviews_spec.rb +++ b/spec/system/plan_reviews_spec.rb @@ -921,7 +921,7 @@ def lose_next_review_result expect(page).to have_no_css(".review-tray__actions button", text: "Approve") end - it "uses the existing dictation composer and keeps the bar within narrow screens in both themes" do + it "uses the existing dictation composer and keeps the bar within narrow screens in both modes" do within(".review-tray") { click_button "Request changes" } expect(page).to have_css('.review-tray [data-controller="coplan--dictation"]') [ "light", "dark" ].each do |theme| From 3db559eecdc518d6e4b460eab2511af44b7e847f Mon Sep 17 00:00:00 2001 From: Salman Mohammed Date: Fri, 30 Oct 2026 16:57:24 -0400 Subject: [PATCH 4/5] Check the default theme at boot and keep decks out of app themes Themes.default now returns a Theme, and an unknown config.default_theme stops boot in development and test, naming the bad value and the valid keys. Other environments log a warning and fall back to Classic. Mermaid reads the palette from each diagram's container instead of , so decks, which reset --diagram-bg, keep the built-in themes without a separate deck flag. The configuration cache keys on the palette's colors. An expanded deck diagram keeps the slide's background, so its built-in colors stay readable in dark mode. A static spec checks that every variable a theme file sets is reset in .deck or the deck's code panel, or is allowlisted with a reason. Co-Authored-By: Claude Opus 5.5 --- docs/THEMES.md | 16 ++++-- .../stylesheets/coplan/expanded-content.css | 2 + .../app/helpers/coplan/application_helper.rb | 2 +- .../controllers/coplan/mermaid_controller.js | 34 +++++++----- engine/app/models/coplan/themes.rb | 19 +++++-- engine/lib/coplan/configuration.rb | 4 +- engine/lib/coplan/engine.rb | 1 + spec/lib/deck_theme_isolation_spec.rb | 53 +++++++++++++++++++ spec/models/themes_spec.rb | 40 ++++++++++++-- 9 files changed, 142 insertions(+), 29 deletions(-) create mode 100644 spec/lib/deck_theme_isolation_spec.rb diff --git a/docs/THEMES.md b/docs/THEMES.md index 054d3aa9..f77f5ed0 100644 --- a/docs/THEMES.md +++ b/docs/THEMES.md @@ -23,8 +23,9 @@ only the hooks it changes. ## 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)), and -falls back to `classic` for an unknown name. Light and dark stay on +`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 themes are listed in `CoPlan::Themes::ALL` @@ -46,7 +47,10 @@ 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. Decks are fixed artifacts. `.deck` sets every document hook to `initial`, -which brings back the built-in value, so themes never restyle slides. +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. ## Tokens @@ -88,5 +92,7 @@ values (Mermaid's color math can't read `oklch()`), per scheme: `--diagram-actor-line`, `--diagram-alt`, `--diagram-note`, `--diagram-note-border`, `--diagram-note-text`. -If any of them is missing, documents keep Mermaid's built-in themes. Decks -always do. +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. diff --git a/engine/app/assets/stylesheets/coplan/expanded-content.css b/engine/app/assets/stylesheets/coplan/expanded-content.css index df0881fa..e1b691eb 100644 --- a/engine/app/assets/stylesheets/coplan/expanded-content.css +++ b/engine/app/assets/stylesheets/coplan/expanded-content.css @@ -140,6 +140,8 @@ /* The browser's own pan and pinch would fight the controller's. */ touch-action: none; cursor: grab; + /* Set for deck diagrams, which keep their slide's canvas. */ + background: var(--expander-canvas-bg, transparent); } .expander__canvas.is-grabbing { diff --git a/engine/app/helpers/coplan/application_helper.rb b/engine/app/helpers/coplan/application_helper.rb index 99fb1fcd..577339b6 100644 --- a/engine/app/helpers/coplan/application_helper.rb +++ b/engine/app/helpers/coplan/application_helper.rb @@ -5,7 +5,7 @@ module ApplicationHelper # The app theme for this page, as (docs/THEMES.md). def coplan_app_theme - CoPlan::Themes.default + CoPlan::Themes.default.key end def coplan_favicon_tag diff --git a/engine/app/javascript/controllers/coplan/mermaid_controller.js b/engine/app/javascript/controllers/coplan/mermaid_controller.js index 8a4e9e08..db97c58f 100644 --- a/engine/app/javascript/controllers/coplan/mermaid_controller.js +++ b/engine/app/javascript/controllers/coplan/mermaid_controller.js @@ -3,7 +3,7 @@ import { openExpander, attachExpandAffordance, mountPanZoom, nearestHeading } fr let diagramId = 0 let mermaidPromise -let configuredTheme +let configuredMermaid export default class extends Controller { connect() { @@ -55,7 +55,7 @@ export default class extends Controller { for (const { container, source } of sources) { // Sequential renders, so re-configuring per diagram is safe (and // a no-op when the theme hasn't changed). - const theme = configureMermaid(mermaid, diagramDark(container), Boolean(container.closest(".deck"))) + const theme = configureMermaid(mermaid, diagramDark(container), diagramPalette(container)) await this.renderDiagram(mermaid, container, source, theme, generation) } } catch { @@ -229,6 +229,11 @@ export default class extends Controller { onClose: () => { this.expanded = null } }) this.expanded = expander + // A deck diagram keeps the deck's colors, so it keeps the slide's canvas + // too, whatever the app's mode and theme paint the expander. + const deck = diagram.closest(".deck") + const deckCanvas = deck && getComputedStyle(deck).getPropertyValue("--deck-bg").trim() + if (deckCanvas) expander.dialog.style.setProperty("--expander-canvas-bg", deckCanvas) const content = svg.cloneNode(true) content.removeAttribute("width") @@ -350,8 +355,8 @@ function naturalSize(svg) { } // A deck is a fixed artifact — its diagrams follow the deck theme, not -// the reader's app scheme, so a dark-mode reader sees the same slides as -// a light-mode one. Outside a deck, diagrams follow the app. The theme +// the reader's mode, so a dark-mode reader sees the same slides as a +// light-mode one. Outside a deck, diagrams follow the app. The deck theme // list is small enough to name here; when the theme picker lands, the // deck can carry scheme metadata instead. function diagramDark(container) { @@ -364,12 +369,14 @@ function diagramDark(container) { } // An app theme can bring its own diagram palette as CSS variables -// (docs/THEMES.md). Decks always keep the built-in themes. -function configureMermaid(mermaid, dark, deck = false) { - const palette = deck ? null : themeDiagramPalette() +// (docs/THEMES.md); without one, Mermaid's built-in themes apply. The +// configuration is keyed on the palette's colors, not just its name, so +// diagrams with different palettes on one page each get their own. +function configureMermaid(mermaid, dark, palette) { const scheme = dark ? "dark" : "light" const theme = palette ? `${palette.name}-${scheme}` : scheme - if (theme === configuredTheme) return theme + const configuration = JSON.stringify([ scheme, palette ]) + if (configuration === configuredMermaid) return theme const fontFamily = appFontStack() mermaid.initialize({ @@ -386,7 +393,7 @@ function configureMermaid(mermaid, dark, deck = false) { sequence: { actorFontSize: 15, messageFontSize: 15, noteFontSize: 14 }, gantt: { fontSize: 14 } }) - configuredTheme = theme + configuredMermaid = configuration return theme } @@ -419,10 +426,11 @@ const DIAGRAM_PALETTE_PROPERTIES = { noteText: "--diagram-note-text" } -// The active theme's palette for the current scheme, or null when the theme -// doesn't define a complete one. -function themeDiagramPalette() { - const style = getComputedStyle(document.documentElement) +// 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 +// in slides always get null and keep Mermaid's built-in themes. +function diagramPalette(container) { + const style = getComputedStyle(container) const palette = {} for (const [key, property] of Object.entries(DIAGRAM_PALETTE_PROPERTIES)) { palette[key] = style.getPropertyValue(property).trim() diff --git a/engine/app/models/coplan/themes.rb b/engine/app/models/coplan/themes.rb index d8367b20..a135b880 100644 --- a/engine/app/models/coplan/themes.rb +++ b/engine/app/models/coplan/themes.rb @@ -16,10 +16,21 @@ def self.keys = ALL.map(&:key) def self.find(key) = ALL.find { |theme| theme.key == key.to_s } - # The host's configured default. Falls back to Classic when the host - # names a theme that doesn't exist. - def self.default - find(CoPlan.configuration.default_theme)&.key || CLASSIC + # The host's configured default theme. Falls back to Classic when the + # host names a theme that doesn't exist. + def self.default = find(CoPlan.configuration.default_theme) || find(CLASSIC) + + # Runs at boot, so a misspelled default doesn't fall back to Classic + # unnoticed. Development and test raise; other environments log a + # warning and keep serving Classic. + def self.check_default!(env: Rails.env, logger: Rails.logger) + name = CoPlan.configuration.default_theme + return if find(name) + + message = "CoPlan config.default_theme is #{name.inspect}, which is not a theme. " \ + "Valid themes: #{keys.join(", ")}." + raise ArgumentError, message if env.development? || env.test? + logger.warn("#{message} Falling back to #{CLASSIC}.") end end end diff --git a/engine/lib/coplan/configuration.rb b/engine/lib/coplan/configuration.rb index c19631da..c7306b6c 100644 --- a/engine/lib/coplan/configuration.rb +++ b/engine/lib/coplan/configuration.rb @@ -155,7 +155,9 @@ class Configuration attr_accessor :reserved_handles # The app theme everyone sees unless they pick another in Settings. One - # of CoPlan::Themes.keys; unknown names fall back to "classic". + # of CoPlan::Themes.keys. An unknown name stops boot in development and + # test; in other environments it logs a warning and falls back to + # "classic". # # Example: # config.default_theme = "classic" diff --git a/engine/lib/coplan/engine.rb b/engine/lib/coplan/engine.rb index fbe074a4..c066cf9e 100644 --- a/engine/lib/coplan/engine.rb +++ b/engine/lib/coplan/engine.rb @@ -3,6 +3,7 @@ class Engine < ::Rails::Engine isolate_namespace CoPlan config.after_initialize do + CoPlan::Themes.check_default! CoPlan::WritingProvenanceBackfillJob.start unless Rails.env.test? || ENV["SECRET_KEY_BASE_DUMMY"] end diff --git a/spec/lib/deck_theme_isolation_spec.rb b/spec/lib/deck_theme_isolation_spec.rb new file mode 100644 index 00000000..42f32c5c --- /dev/null +++ b/spec/lib/deck_theme_isolation_spec.rb @@ -0,0 +1,53 @@ +require "rails_helper" + +# A deck is a fixed artifact: app themes must not restyle its slides +# (docs/THEMES.md). This reads the stylesheets instead of rendering them, so +# a new theme variable fails here until deck.css resets it or the allowlist +# below says why the deck doesn't need it. +RSpec.describe "Deck isolation from app themes" do + stylesheets = CoPlan::Engine.root.join("app/assets/stylesheets/coplan") + + # Prefixes and names a theme may set without a deck reset, with the reason. + allowed = { + "--color-" => "App tokens. In a deck they paint only the slide frame and the discussion controls around diagrams, which follow the app. Slide content reads --deck-* instead.", + "--code-block-" => "`.deck pre` declares its own background, color, border, font-size and line-height.", + "--diagram-" => "Mermaid reads the palette from the diagram's container, and `.deck` resets --diagram-bg, which turns the palette off on slides.", + "--reading-" => "Reading type rules skip .deck-content.", + "--plan-title-" => "App chrome outside slides.", + "--document-" => "App chrome outside slides.", + "--window-" => "App chrome outside slides.", + "--panel-" => "App chrome outside slides.", + "--overlay-" => "App chrome outside slides.", + "--glass-" => "App chrome outside slides.", + "--shadow-" => "App chrome outside slides.", + "--page-backdrop" => "App chrome outside slides.", + "--dialog-backdrop" => "App chrome outside slides.", + "--composer-input-bg" => "App chrome outside slides.", + "--surface-translucent" => "App chrome outside slides.", + "--card-edge" => "App chrome outside slides." + } + + declared = ->(css) { css.scan(/^\s*(--[\w-]+)\s*:/).flatten.uniq } + block = lambda do |css, selector| + css[/^#{Regexp.escape(selector)} \{\n(.*?)^\}/m, 1] or raise "#{selector} not found in deck.css" + end + + deck_css = stylesheets.join("deck.css").read + resets = declared.(block.(deck_css, ".deck")) + declared.(block.(deck_css, ".deck-slide .deck-content pre")) + + it "finds the deck's resets" do + expect(resets).to include("--diagram-bg", "--strong-weight", "--code-label-bg", "--code-keyword") + end + + Dir[stylesheets.join("themes/*.css")].sort.each do |path| + it "resets or allowlists every variable #{File.basename(path)} sets" do + leaks = declared.(File.read(path)).reject do |name| + resets.include?(name) || allowed.keys.any? { |prefix| name.start_with?(prefix) } + end + + expect(leaks).to be_empty, + "Reset these in `.deck` or `.deck-slide .deck-content pre` in deck.css, " \ + "or add them to the allowlist in this spec with a reason: #{leaks.join(", ")}" + end + end +end diff --git a/spec/models/themes_spec.rb b/spec/models/themes_spec.rb index 039fe522..1d185d3b 100644 --- a/spec/models/themes_spec.rb +++ b/spec/models/themes_spec.rb @@ -11,20 +11,50 @@ it "ships Classic as the default theme" do expect(described_class.keys).to include("classic") expect(CoPlan.configuration.default_theme).to eq("classic") - expect(described_class.default).to eq("classic") + expect(described_class.default).to eq(described_class.find("classic")) end - it "uses the host's configured default" do - CoPlan.configuration.default_theme = :classic - expect(described_class.default).to eq("classic") + it "uses the host's configured default, given as a string or a symbol" do + paper = described_class::Theme.new(key: "paper", name: "Paper", description: "A test theme.") + stub_const("CoPlan::Themes::ALL", [ *described_class::ALL, paper ]) + + CoPlan.configuration.default_theme = "paper" + expect(described_class.default).to eq(paper) + CoPlan.configuration.default_theme = :paper + expect(described_class.default).to eq(paper) end it "falls back to Classic for an unknown theme" do CoPlan.configuration.default_theme = "hotdog-stand" - expect(described_class.default).to eq("classic") + expect(described_class.default.key).to eq("classic") expect(described_class.find("hotdog-stand")).to be_nil end + describe ".check_default!" do + let(:logger) { instance_double(Logger, warn: nil) } + + it "accepts a known theme" do + expect { described_class.check_default!(env: "production".inquiry, logger: logger) }.not_to raise_error + expect(logger).not_to have_received(:warn) + end + + it "raises in development and test, naming the bad value and the valid keys" do + CoPlan.configuration.default_theme = "hotdog-stand" + + %w[development test].each do |env| + expect { described_class.check_default!(env: env.inquiry, logger: logger) } + .to raise_error(ArgumentError, /"hotdog-stand".*Valid themes: #{described_class.keys.join(", ")}\./) + end + end + + it "only warns in production" do + CoPlan.configuration.default_theme = "hotdog-stand" + + described_class.check_default!(env: "production".inquiry, logger: logger) + expect(logger).to have_received(:warn).with(/"hotdog-stand".*Valid themes: #{described_class.keys.join(", ")}\./) + end + end + it "has a stylesheet for every theme except Classic" do manifest = CoPlan::Engine.root.join("app/assets/stylesheets/coplan/application.css").read (described_class.keys - [ "classic" ]).each do |key| From 76dc981dfa0d850ba0112492c36537c19d95c6e8 Mon Sep 17 00:00:00 2001 From: Salman Mohammed Date: Fri, 30 Oct 2026 14:22:00 -0400 Subject: [PATCH 5/5] Keep deck diagram labels in the deck's font Mermaid labels took their font from , so a theme that changes --font-sans would restyle diagrams inside slides too. foundation.css now names the built-in stacks --font-builtin-sans and --font-builtin-mono, and decks set --font-sans and --font-mono back to them. Mermaid reads the font from the diagram's container, and its configuration cache keys on the font as well as the palette, so a deck and a document diagram on one page each get their own. Classic looks the same. Co-Authored-By: Claude Opus 5.5 --- engine/app/assets/stylesheets/coplan/deck.css | 5 +++-- .../assets/stylesheets/coplan/foundation.css | 9 ++++++--- .../controllers/coplan/mermaid_controller.js | 19 ++++++++++--------- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/engine/app/assets/stylesheets/coplan/deck.css b/engine/app/assets/stylesheets/coplan/deck.css index 2fc95f19..f68dc5c1 100644 --- a/engine/app/assets/stylesheets/coplan/deck.css +++ b/engine/app/assets/stylesheets/coplan/deck.css @@ -22,8 +22,9 @@ gap: 2rem; /* A deck is a fixed artifact: app themes don't restyle it. Setting an app theme hook to initial brings back its built-in value (docs/THEMES.md). - Code keeps the app's original mono stack. */ - --font-mono: "Hack", "SF Mono", SFMono-Regular, ui-monospace, Menlo, monospace; + Diagram labels and code keep the app's original sans and mono stacks. */ + --font-sans: var(--font-builtin-sans); + --font-mono: var(--font-builtin-mono); --strong-weight: initial; --code-chip-padding: initial; --code-chip-radius: initial; diff --git a/engine/app/assets/stylesheets/coplan/foundation.css b/engine/app/assets/stylesheets/coplan/foundation.css index 37e42f13..4a6fa302 100644 --- a/engine/app/assets/stylesheets/coplan/foundation.css +++ b/engine/app/assets/stylesheets/coplan/foundation.css @@ -165,9 +165,12 @@ body[data-writing-inspection] :is(#plan-toolbar a, .site-nav__edit) { opacity: . --space-xl: 2rem; --space-2xl: 3rem; - /* Typography */ - --font-sans: 'Lexend', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - --font-mono: "Hack", "SF Mono", SFMono-Regular, ui-monospace, Menlo, monospace; + /* Typography. Themes change --font-sans and --font-mono; the built-in + stacks stay named so decks can keep them (deck.css). */ + --font-builtin-sans: 'Lexend', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --font-builtin-mono: "Hack", "SF Mono", SFMono-Regular, ui-monospace, Menlo, monospace; + --font-sans: var(--font-builtin-sans); + --font-mono: var(--font-builtin-mono); --text-sm: 0.875rem; --text-base: 1rem; --text-lg: 1.125rem; diff --git a/engine/app/javascript/controllers/coplan/mermaid_controller.js b/engine/app/javascript/controllers/coplan/mermaid_controller.js index db97c58f..4c6e103a 100644 --- a/engine/app/javascript/controllers/coplan/mermaid_controller.js +++ b/engine/app/javascript/controllers/coplan/mermaid_controller.js @@ -55,7 +55,7 @@ export default class extends Controller { for (const { container, source } of sources) { // Sequential renders, so re-configuring per diagram is safe (and // a no-op when the theme hasn't changed). - const theme = configureMermaid(mermaid, diagramDark(container), diagramPalette(container)) + const theme = configureMermaid(mermaid, diagramDark(container), diagramPalette(container), fontStack(container)) await this.renderDiagram(mermaid, container, source, theme, generation) } } catch { @@ -369,16 +369,17 @@ function diagramDark(container) { } // An app theme can bring its own diagram palette as CSS variables -// (docs/THEMES.md); without one, Mermaid's built-in themes apply. The -// configuration is keyed on the palette's colors, not just its name, so -// diagrams with different palettes on one page each get their own. -function configureMermaid(mermaid, dark, palette) { +// (docs/THEMES.md); without one, Mermaid's built-in themes apply. Labels +// use the font of the diagram's surroundings, which decks pin. The +// configuration is keyed on the palette's colors and the font, not just the +// palette's name, so diagrams with different surroundings on one page each +// get their own. +function configureMermaid(mermaid, dark, palette, fontFamily) { const scheme = dark ? "dark" : "light" const theme = palette ? `${palette.name}-${scheme}` : scheme - const configuration = JSON.stringify([ scheme, palette ]) + const configuration = JSON.stringify([ scheme, palette, fontFamily ]) if (configuration === configuredMermaid) return theme - const fontFamily = appFontStack() mermaid.initialize({ startOnLoad: false, securityLevel: "strict", @@ -405,8 +406,8 @@ const THEME_CSS = ` .cluster rect { stroke-width: 1.25px; } ` -function appFontStack() { - const declared = getComputedStyle(document.documentElement).getPropertyValue("--font-sans").trim() +function fontStack(element) { + const declared = getComputedStyle(element).getPropertyValue("--font-sans").trim() return declared || "system-ui, sans-serif" }