<%= link_to coplan.root_path, class: "site-nav__brand" do %>
- <%= image_tag "/coplan-logo-sm.png", alt: "CoPlan", class: "site-nav__logo" %>
+ <%= image_tag "/coplan-logo-sm.png", alt: "", class: "site-nav__logo" %>
CoPlan
<% end %>
diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css
index 9612bfff..4cbf2908 100644
--- a/engine/app/assets/stylesheets/coplan/application.css
+++ b/engine/app/assets/stylesheets/coplan/application.css
@@ -40,7 +40,7 @@
--color-surface-muted: #f9fafb;
--color-input-bg: var(--color-surface);
--color-text: #1a1a1a;
- --color-text-muted: #6b7280;
+ --color-text-muted: #646b78;
--color-text-inverse: #ffffff;
--color-border: #e5e7eb;
--color-border-strong: #cbd5e1;
@@ -53,6 +53,7 @@
--color-interaction-hover-bg: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
--color-interaction-active-bg: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface));
--color-warning: #d97706;
+ --color-warning-text: #92400e;
--color-warning-soft: #fef3c7;
--color-danger: #dc2626;
--color-danger-hover: #b91c1c;
@@ -182,6 +183,7 @@
--color-success: #34d399;
--color-success-soft: rgba(52, 211, 153, 0.15);
--color-warning: #f59e0b;
+ --color-warning-text: #f59e0b;
--color-warning-soft: rgba(245, 158, 11, 0.16);
--color-danger: #f87171;
--color-danger-hover: #ef4444;
@@ -275,6 +277,7 @@
--color-success: #34d399;
--color-success-soft: rgba(52, 211, 153, 0.15);
--color-warning: #f59e0b;
+ --color-warning-text: #f59e0b;
--color-warning-soft: rgba(245, 158, 11, 0.16);
--color-danger: #f87171;
--color-danger-hover: #ef4444;
@@ -498,8 +501,8 @@ img, svg {
}
.site-nav__menu-btn:hover {
- color: var(--color-text);
- background: var(--color-bg);
+ color: var(--color-primary-hover);
+ background: var(--color-interaction-hover-bg);
}
/* When the bell has folded into the menu (phone widths), the ☰ carries a
@@ -590,6 +593,18 @@ img, svg {
}
/* Main content */
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ border: 0;
+}
+
.main-content {
flex: 1;
max-width: var(--max-width);
@@ -1277,7 +1292,8 @@ del.agent-flash {
.avatar--xl { width: 4rem; height: 4rem; font-size: 1.3rem; }
.avatar--initials {
- background: var(--color-primary);
+ /* White initials need a deeper blue than the dark theme's link color. */
+ background: #2563eb;
color: var(--color-text-inverse);
font-weight: 600;
}
@@ -2905,6 +2921,17 @@ img.avatar {
}
/* Data table */
+.data-table-scroll {
+ position: relative;
+ max-width: 100%;
+ overflow-x: auto;
+}
+
+.data-table-scroll:focus-visible {
+ outline: 2px solid var(--color-primary);
+ outline-offset: 2px;
+}
+
.data-table {
width: 100%;
border-collapse: collapse;
@@ -3394,12 +3421,12 @@ img.avatar {
align-items: center;
justify-content: center;
border-radius: 8px;
- color: var(--color-text-inverse);
+ color: var(--color-primary);
line-height: 1;
}
.content-nav__badge--open {
- background: var(--color-status-considering);
+ background: var(--color-primary-light);
}
/* Toggle button when sidebar is hidden — shown outside the sidebar */
@@ -3545,8 +3572,8 @@ img.avatar {
}
.site-nav__bell:hover {
- color: var(--color-text);
- background: var(--color-bg);
+ color: var(--color-primary-hover);
+ background: var(--color-interaction-hover-bg);
}
.site-nav__bell .inbox-badge {
@@ -3806,18 +3833,24 @@ img.avatar {
width: 24px;
height: 24px;
padding: 0;
- background: var(--color-bg-muted);
- border: 1px solid var(--color-border);
+ background: var(--glass-bg);
+ border: 1px solid var(--glass-edge);
border-radius: 50%;
+ -webkit-backdrop-filter: var(--glass-blur);
+ backdrop-filter: var(--glass-blur);
color: var(--color-text-muted);
cursor: pointer;
transition: color 0.12s ease, border-color 0.12s ease, background 0.12s ease;
}
.section-add:hover {
- color: var(--color-primary);
- border-color: var(--color-primary);
- background: var(--color-bg);
+ color: var(--color-primary-hover);
+ background: var(--color-interaction-hover-bg);
+}
+
+.section-add:focus-visible {
+ outline: 2px solid var(--color-primary);
+ outline-offset: 2px;
}
/* Back-matter links at the bottom of the document outline. */
@@ -5324,10 +5357,10 @@ img.avatar {
gap: var(--space-sm);
padding: 6px 10px;
margin: 0;
- flex: 0 1 320px;
+ flex: 0 0 auto;
min-width: 0;
- background: var(--color-bg);
- border: 1px solid var(--color-border);
+ background: transparent;
+ border: 1px solid var(--color-border-strong);
border-radius: var(--radius);
color: var(--color-text-muted);
font: inherit;
@@ -5338,22 +5371,27 @@ img.avatar {
}
.site-nav__search:hover {
- border-color: var(--color-text-muted);
- color: var(--color-text);
+ border-color: var(--color-border-strong);
+ background: var(--color-interaction-hover-bg);
+ color: var(--color-primary);
+}
+
+.site-nav__search:focus-visible,
+.site-nav__bell:focus-visible,
+.site-nav__menu-btn:focus-visible {
+ outline: 2px solid var(--color-primary);
+ outline-offset: 3px;
}
.site-nav__search-label {
- flex: 1;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
+ display: none;
}
.site-nav__search-kbd {
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 11px;
padding: 1px 6px;
- background: var(--color-surface);
+ background: transparent;
border: 1px solid var(--color-border);
border-radius: 4px;
color: var(--color-text-muted);
@@ -5402,6 +5440,11 @@ img.avatar {
border-bottom: 1px solid var(--color-border);
}
+.search-modal__icon {
+ flex-shrink: 0;
+ color: var(--color-text-muted);
+}
+
.search-modal__input {
flex: 1;
border: 0;
@@ -5413,6 +5456,11 @@ img.avatar {
padding: 4px 0;
}
+.search-modal__input::placeholder {
+ color: var(--color-text-muted);
+ opacity: 1;
+}
+
.search-modal__hint {
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 11px;
@@ -6663,7 +6711,7 @@ img.avatar {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
- color: var(--color-warning);
+ color: var(--color-warning-text);
}
.attention__list {
@@ -6867,14 +6915,14 @@ img.avatar {
background: var(--glass-bg);
-webkit-backdrop-filter: var(--glass-blur);
backdrop-filter: var(--glass-blur);
- color: var(--color-text);
- box-shadow: var(--shadow);
+ color: var(--color-text-muted);
+ box-shadow: var(--shadow-glass);
}
.workspace-add__trigger:hover,
.workspace-add:has(.workspace-add__menu:popover-open) .workspace-add__trigger {
- background: var(--glass-bg-strong);
- border-color: color-mix(in srgb, var(--color-primary) 30%, var(--glass-edge));
+ background: var(--color-interaction-hover-bg);
+ color: var(--color-primary-hover);
}
.workspace-add__trigger:focus-visible {
@@ -7154,12 +7202,10 @@ img.avatar {
}
/* ---- Medium screens ----
- The first thing the nav gives up is the search field's prose: the
- placeholder and "/" hint fold into a round icon button before anything
- else is allowed to compress. The name gets a truncation budget so the
- controls never fight it for room. */
+ The compact search trigger gives up its "/" hint and becomes a round
+ icon button. The name gets a truncation budget so the controls never
+ fight it for room. */
@media (max-width: 900px) {
- .site-nav__search-label,
.site-nav__search-kbd {
display: none;
}
diff --git a/engine/app/javascript/controllers/coplan/editor_controller.js b/engine/app/javascript/controllers/coplan/editor_controller.js
index 756b1cae..688cd4ef 100644
--- a/engine/app/javascript/controllers/coplan/editor_controller.js
+++ b/engine/app/javascript/controllers/coplan/editor_controller.js
@@ -379,6 +379,7 @@ export default class extends Controller {
const command = event.currentTarget.dataset.command
if (this.editor !== this.richEditor && !["undo", "redo"].includes(command)) return
const editor = ["undo", "redo"].includes(command) ? this.editor : this.richEditor
+ if (!["undo", "redo"].includes(command)) editor?.captureSelection()
editor?.command(command, event.currentTarget.value)
}
keepSelection(event) { if (event.target.closest("button")) event.preventDefault() }
@@ -652,6 +653,9 @@ export default class extends Controller {
setTimeout(() => { if (button.isConnected) button.textContent = "Copy" }, 1400)
} catch { this.setStatus("Could not copy this block", "error") }
}
+ // A language edit is separate from typing code, even within history's
+ // grouping delay. Undoing code must retain the chosen grammar.
+ languageEditingBoundary() { this.richEditor?.closeHistory() }
languageInput(event) { this.languageChanged(event) }
languageChanged(event) {
event.stopPropagation()
diff --git a/engine/app/javascript/controllers/coplan/search_controller.js b/engine/app/javascript/controllers/coplan/search_controller.js
index 351043ac..fd910f2e 100644
--- a/engine/app/javascript/controllers/coplan/search_controller.js
+++ b/engine/app/javascript/controllers/coplan/search_controller.js
@@ -15,7 +15,7 @@ import { registerShortcuts, commandFor } from "coplan/shortcuts"
// element.showPopover() / hidePopover() — the browser handles the backdrop,
// top-layer, and outside-click dismiss.
export default class extends Controller {
- static targets = ["input", "body"]
+ static targets = ["input", "body", "announcement"]
static values = {
url: String,
debounce: { type: Number, default: 150 }
@@ -30,29 +30,44 @@ export default class extends Controller {
disconnect() {
this.releaseShortcuts()
if (this._debounceTimer) clearTimeout(this._debounceTimer)
+ this._openingAnimation?.cancel()
}
// Fires when the popover opens or closes (newState: "open" | "closed").
onToggle(event) {
if (event.newState === "open") {
- // Focus the input and clear it so each open starts fresh.
+ // Select the query so reopening search is ready for new input.
requestAnimationFrame(() => {
+ if (!this.element.matches(":popover-open")) return
+ this._animateOpen()
this.inputTarget.focus()
this.inputTarget.select()
+ this.resultsLoaded()
})
} else {
+ this._openingAnimation?.cancel()
this._cancelDebounce()
+ this.inputTarget.setAttribute("aria-expanded", "false")
+ this.inputTarget.removeAttribute("aria-activedescendant")
}
}
// Debounced input handler — schedules a frame fetch.
onInput() {
this._cancelDebounce()
+ this._selectedIndex = -1
+ this._applySelection(this._resultItems())
this._debounceTimer = setTimeout(() => this._fetchResults(), this.debounceValue)
}
// Keyboard nav within the input box.
onKeydown(event) {
+ if (event.isComposing) return
+ if (event.key === "Escape") {
+ event.preventDefault()
+ this.element.hidePopover()
+ return
+ }
const items = this._resultItems()
switch (commandFor("results", event)) {
case "next":
@@ -83,6 +98,22 @@ export default class extends Controller {
// --- private ---
+ _animateOpen() {
+ this._openingAnimation?.cancel()
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
+ const trigger = document.querySelector(`[popovertarget="${this.element.id}"]`)
+ if (!trigger) return
+ const origin = trigger.getBoundingClientRect()
+ const destination = this.element.getBoundingClientRect()
+ if (!origin.width || !origin.height || !destination.width || !destination.height) return
+ const x = origin.left + origin.width / 2 - destination.left - destination.width / 2
+ const y = origin.top + origin.height / 2 - destination.top - destination.height / 2
+ this._openingAnimation = this.element.animate([
+ { transform: `translateX(-50%) translate(${x}px, ${y}px) scale(${origin.width / destination.width}, ${origin.height / destination.height})`, opacity: 0 },
+ { transform: "translateX(-50%) translate(0, 0) scale(1, 1)", opacity: 1 }
+ ], { duration: 180, easing: "cubic-bezier(0.2, 0.8, 0.2, 1)" })
+ }
+
_onGlobalKeydown(event) {
if (commandFor("search", event) !== "open") return
event.preventDefault()
@@ -103,21 +134,26 @@ export default class extends Controller {
const url = new URL(this.urlValue, window.location.origin)
url.searchParams.set("q", query)
url.searchParams.set("frame", "results")
- frame.src = url.toString()
// Reset selection — the frame is about to be replaced.
this._selectedIndex = -1
- frame.addEventListener("turbo:frame-load", () => this._afterFrameLoad(), { once: true })
+ this._applySelection([])
+ this.announcementTarget.textContent = "Searching…"
+ frame.src = url.toString()
}
- _afterFrameLoad() {
+ resultsLoaded(event) {
+ if (event && event.target.id !== "search-results") return
+ const listbox = this.bodyTarget.querySelector("#search-results-listbox")
+ if (listbox?.dataset.searchQuery !== this.inputTarget.value.trim()) return
const items = this._resultItems()
- if (items.length > 0) {
- this._selectedIndex = 0
- this._applySelection(items)
- }
+ this._selectedIndex = items.length > 0 ? 0 : -1
+ this._applySelection(items)
+ this.announcementTarget.textContent = this.bodyTarget.querySelector("[data-search-summary]")?.textContent || ""
}
_resultItems() {
+ const listbox = this.bodyTarget.querySelector("#search-results-listbox")
+ if (listbox?.dataset.searchQuery !== this.inputTarget.value.trim()) return []
return Array.from(this.bodyTarget.querySelectorAll("[data-search-result]"))
}
@@ -128,11 +164,19 @@ export default class extends Controller {
}
_applySelection(items) {
+ const open = this.element.matches(":popover-open")
+ this.inputTarget.setAttribute("aria-expanded", String(open && items.length > 0))
+ this.inputTarget.removeAttribute("aria-activedescendant")
+ this.bodyTarget.querySelectorAll(".search-modal__result--selected").forEach(el => {
+ el.classList.remove("search-modal__result--selected")
+ el.setAttribute("aria-selected", "false")
+ })
items.forEach((el, i) => {
const selected = i === this._selectedIndex
el.classList.toggle("search-modal__result--selected", selected)
el.setAttribute("aria-selected", selected ? "true" : "false")
- if (selected) {
+ if (selected && open) {
+ this.inputTarget.setAttribute("aria-activedescendant", el.id)
el.scrollIntoView({ block: "nearest" })
}
})
diff --git a/engine/app/javascript/coplan/rich_document.js b/engine/app/javascript/coplan/rich_document.js
index 15d3b8d9..a0d4c92f 100644
--- a/engine/app/javascript/coplan/rich_document.js
+++ b/engine/app/javascript/coplan/rich_document.js
@@ -269,6 +269,7 @@ export function createRichDocument(element, markdown, changed, selectionChanged
},
toolbarState: () => toolbarState(view.state),
historyState: () => ({ undo: undo(view.state), redo: redo(view.state) }),
+ closeHistory() { view.dispatch(closeHistory(view.state.tr)) },
update(markdown) {
if (serializeDocument(view.state.doc) === markdown) return
const next = parseDocument(markdown)
@@ -634,7 +635,7 @@ function codeNodeView(initial, view, getPos, preview, sourceRangeAt) {
}
const language = document.createElement("input"); language.type = "text"; language.className = "document-editor__code-language"
language.setAttribute("aria-label", "Code language"); language.placeholder = "Plain text"; language.setAttribute("list", "coplan-code-languages")
- language.dataset.action = "input->coplan--editor#languageInput change->coplan--editor#languageChanged"
+ language.dataset.action = "focus->coplan--editor#languageEditingBoundary input->coplan--editor#languageInput change->coplan--editor#languageChanged blur->coplan--editor#languageEditingBoundary"
title.replaceWith(controls, language)
const pre = document.createElement("pre"), contentDOM = document.createElement("code"); pre.append(contentDOM); dom.append(pre)
const diagram = document.createElement("div"); diagram.className = "document-editor__block-preview"; diagram.contentEditable = "false"; dom.append(diagram)
diff --git a/engine/app/views/coplan/plans/_editor_form.html.erb b/engine/app/views/coplan/plans/_editor_form.html.erb
index 7b9a71a0..4bc014d5 100644
--- a/engine/app/views/coplan/plans/_editor_form.html.erb
+++ b/engine/app/views/coplan/plans/_editor_form.html.erb
@@ -116,7 +116,7 @@
data-action="mouseup->coplan--text-selection#handleEditorMouseUp"<% end %>>
- <%= text_area_tag :content, @draft_content, hidden: true, data: { "coplan--editor-target": "textarea" } %>
+ <%= text_area_tag :content, @draft_content, hidden: true, aria: { label: "Document Markdown" }, data: { "coplan--editor-target": "textarea" } %>
<%= hidden_field_tag :base_revision, @base_revision %>
<% end %>
diff --git a/engine/app/views/coplan/search/_modal.html.erb b/engine/app/views/coplan/search/_modal.html.erb
index c6332a7a..7e4adce8 100644
--- a/engine/app/views/coplan/search/_modal.html.erb
+++ b/engine/app/views/coplan/search/_modal.html.erb
@@ -1,6 +1,6 @@
<%# Sitewide search modal, rendered once per page in the global layout.
Opens via:
- * Click on the header bar ("Search plans…")
+ * Click the header's magnifying glass
* The `/` keyboard shortcut from anywhere outside an input
Built on the native HTML Popover API (`popover="auto"`) to match the
@@ -10,6 +10,8 @@
keyboard navigation, and `/` shortcut wiring. %>
-
+
+
<%# Modal is only rendered for signed-in users (see layout), so the
recent-searches lookup here is always safe. %>
<%= render partial: "coplan/search/results", locals: {
diff --git a/engine/app/views/coplan/search/_results.html.erb b/engine/app/views/coplan/search/_results.html.erb
index 9a57d898..a401edac 100644
--- a/engine/app/views/coplan/search/_results.html.erb
+++ b/engine/app/views/coplan/search/_results.html.erb
@@ -9,85 +9,89 @@
`[data-search-result]` items; keep that hook intact when changing markup. %>
<% frame_id = local_assigns.fetch(:frame_id, "search-results") %>
<% people = local_assigns.fetch(:people, []) %>
+<% modal = frame_id == "search-results" %>
+<% result_count = query.blank? ? recent_queries.size : people.size + results.size %>
<%# `target="_top"` so clicks on result anchors inside this frame navigate
the whole page rather than trying to load the plan page into the frame
(which would render "Content missing" since no matching frame exists
on the target page). %>
- <% if query.blank? %>
- <% if recent_queries.any? %>
- Recent searches
-
- <% else %>
- Type to search plans.
- <% end %>
- <% elsif results.empty? && people.empty? %>
- Nothing matches “<%= query %>”.
+ <% if result_count.positive? %>
+ role="status"<% end %>><%= pluralize(result_count, query.blank? ? "recent search" : "result") %> available.
+ <% elsif query.present? %>
+ role="status"<% end %>>Nothing matches “<%= query %>”.
<% else %>
- <% if people.any? %>
- People
-
+ <% end %>
+
role="listbox" aria-label="Search results"<% end %><% if result_count.zero? %> hidden<% end %>>
+ <% if query.blank? %>
+ <% if recent_queries.any? %>
+
aria-hidden="true"<% end %>>Recent searches
+
+ <% end %>
+ <% else %>
+ <% if people.any? %>
+
aria-hidden="true"<% end %>>People
+
+ <% if results.any? %>
aria-hidden="true"<% end %>>Plans
<% end %>
+ <% end %>
+
- <% if results.any? %>
Plans
<% end %>
<% end %>
-
- <% end %>
+
diff --git a/engine/app/views/coplan/search/index.html.erb b/engine/app/views/coplan/search/index.html.erb
index 82340622..9e904480 100644
--- a/engine/app/views/coplan/search/index.html.erb
+++ b/engine/app/views/coplan/search/index.html.erb
@@ -8,7 +8,7 @@
diff --git a/engine/app/views/coplan/settings/tokens/_tokens.html.erb b/engine/app/views/coplan/settings/tokens/_tokens.html.erb
index 593efbad..35b063c8 100644
--- a/engine/app/views/coplan/settings/tokens/_tokens.html.erb
+++ b/engine/app/views/coplan/settings/tokens/_tokens.html.erb
@@ -12,19 +12,21 @@
-
Your Tokens
-
-
-
- | Name |
- Status |
- Last Used |
- Created |
- |
-
-
-
- <%= render partial: "coplan/settings/tokens/token_row", collection: api_tokens, as: :token %>
-
-
+
Your Tokens
+
diff --git a/engine/app/views/coplan/settings/tokens/index.html.erb b/engine/app/views/coplan/settings/tokens/index.html.erb
index c04e1666..d7645170 100644
--- a/engine/app/views/coplan/settings/tokens/index.html.erb
+++ b/engine/app/views/coplan/settings/tokens/index.html.erb
@@ -2,32 +2,4 @@
Settings
-
Manage API tokens for agent access
-
-
- <% if flash[:raw_token].present? %>
- <%= render "token_reveal", raw_token: flash[:raw_token] %>
- <% end %>
-
-
-
- <%= render "form" %>
-
-
-
-
Your Tokens
-
-
-
- | Name |
- Status |
- Last Used |
- Created |
- |
-
-
-
- <%= render partial: "token_row", collection: @api_tokens, as: :token %>
-
-
-
+<%= render "coplan/settings/tokens/tokens", api_tokens: @api_tokens %>
diff --git a/engine/app/views/layouts/coplan/application.html.erb b/engine/app/views/layouts/coplan/application.html.erb
index 14eacca5..6aa6ce60 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" %>
@@ -34,7 +34,7 @@
lives, so there's no separate "Workspace" link. Signed-out
visitors land on the public root instead. %>
<%= link_to(signed_in? ? profile_path_for(current_user) : coplan.root_path, class: "site-nav__brand") do %>
- <%= image_tag "coplan/coplan-logo-sm.png", alt: "CoPlan",
+ <%= image_tag "coplan/coplan-logo-sm.png", alt: "",
class: "site-nav__logo#{coplan_environment_logo_modifier}",
title: coplan_environment_logo_title %>
CoPlan
@@ -53,6 +53,7 @@