diff --git a/app/admin/folders.rb b/app/admin/folders.rb index 3a379971..01fde64f 100644 --- a/app/admin/folders.rb +++ b/app/admin/folders.rb @@ -1,11 +1,12 @@ ActiveAdmin.register CoPlan::Folder, as: "Folder" do - permit_params :name, :parent_id, :created_by_user_id + permit_params :name, :parent_id, :library_id, :created_by_user_id index do selectable_column id_column column :name column("Path") { |folder| folder.path } + column :library column :parent column :created_by_user column :created_at @@ -17,6 +18,7 @@ row :id row :name row("Path") { resource.path } + row :library row :parent row :created_by_user row :created_at @@ -35,7 +37,8 @@ table_for resource.plans.order(updated_at: :desc) do column :id column :title - column :status + column :visibility + column :archived_at column :updated_at end end @@ -44,6 +47,8 @@ form do |f| f.inputs do f.input :name + f.input :library_id, as: :select, + collection: CoPlan::Library.includes(:owner).map { |l| [ "#{l.owner.try(:name) || l.owner_id} — #{l.name}", l.id ] } f.input :parent, collection: CoPlan::Folder.order(:name).map { |folder| [folder.path, folder.id] } f.input :created_by_user, collection: CoPlan::User.order(:name).map { |u| [u.name, u.id] } end diff --git a/app/admin/plan_types.rb b/app/admin/plan_types.rb index a670e4f0..2f4d8474 100644 --- a/app/admin/plan_types.rb +++ b/app/admin/plan_types.rb @@ -1,19 +1,37 @@ ActiveAdmin.register CoPlan::PlanType, as: "PlanType" do - permit_params :name, :description, :template_content + permit_params :name, :description, :icon, :template_content index do selectable_column id_column column :name + column :icon column :description column :created_at actions end + form do |f| + f.inputs do + f.input :name + f.input :description + # Curated set only (CoPlan::PlansHelper::PLAN_TYPE_ICONS) — icons are + # picked by name and rendered from built-in SVG, so nothing + # user-supplied ever reaches the page as markup. The tint is derived + # from the type's name automatically. + f.input :icon, as: :select, + collection: CoPlan::PlansHelper::PLAN_TYPE_ICONS.keys, + include_blank: "(default document icon)" + f.input :template_content, as: :text + end + f.actions + end + show do attributes_table do row :id row :name + row :icon row :description row :default_tags row :template_content diff --git a/app/admin/plans.rb b/app/admin/plans.rb index 14aa3102..13c2ed30 100644 --- a/app/admin/plans.rb +++ b/app/admin/plans.rb @@ -1,8 +1,9 @@ ActiveAdmin.register CoPlan::Plan, as: "Plan" do - permit_params :title, :status + permit_params :title, :visibility, :archived_at filter :title - filter :status, as: :select, collection: CoPlan::Plan::STATUSES + filter :visibility, as: :select, collection: CoPlan::Plan::VISIBILITIES + filter :archived_at filter :plan_type, as: :select filter :created_at filter :updated_at @@ -11,7 +12,8 @@ selectable_column id_column column :title - column :status + column :visibility + column("Archived") { |plan| plan.archived? } column :current_revision column :created_by_user column :updated_at @@ -22,7 +24,8 @@ attributes_table do row :id row :title - row :status + row :visibility + row :archived_at row :current_revision row :created_by_user row(:tags) { |plan| plan.tag_names.join(", ") } diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css deleted file mode 100644 index 940b6678..00000000 --- a/app/assets/stylesheets/application.css +++ /dev/null @@ -1,1075 +0,0 @@ -/* CoPlan — Design Tokens + Base Styles */ - -:root { - color-scheme: light; - - /* Colors */ - --color-bg: #fafafa; - --color-bg-muted: #f3f4f6; - --color-surface: #ffffff; - --color-surface-muted: #f9fafb; - --color-input-bg: var(--color-surface); - --color-text: #1a1a1a; - --color-text-muted: #6b7280; - --color-text-inverse: #ffffff; - --color-border: #e5e7eb; - --color-border-strong: #cbd5e1; - --color-primary: #2563eb; - --color-primary-hover: #1d4ed8; - --color-primary-light: #eff6ff; - --color-success: #059669; - --color-success-soft: #ecfdf5; - --color-warning: #d97706; - --color-warning-soft: #fef3c7; - --color-danger: #dc2626; - --color-danger-hover: #b91c1c; - --color-danger-soft: #fef2f2; - --color-type-bg: #f0fdf4; - --color-type-text: #166534; - --color-tag-bg: #f3f4f6; - --color-tag-text: #374151; - --color-tag-hover-bg: #e5e7eb; - --color-tag-hover-text: #111827; - --color-tag-active-bg: #374151; - --color-tag-active-hover-bg: #1f2937; - --color-code-bg: #f6f8fa; - --color-overlay: rgba(0, 0, 0, 0.2); - --color-focus-ring: rgba(37, 99, 235, 0.12); - - /* Status colors */ - --color-status-brainstorm: #8b5cf6; - --color-status-brainstorm-bg: #ede9fe; - --color-status-considering: #f59e0b; - --color-status-considering-bg: #fef3c7; - --color-status-developing: #3b82f6; - --color-status-developing-bg: #dbeafe; - --color-status-live: #10b981; - --color-status-live-bg: #d1fae5; - --color-status-abandoned: #6b7280; - --color-status-abandoned-bg: #f3f4f6; - --color-diff-ins-bg: #d4edda; - --color-diff-ins-text: #155724; - --color-diff-del-bg: #f8d7da; - --color-diff-del-text: #721c24; - --color-quote-warning-bg: rgba(255, 213, 79, 0.1); - --color-quote-warning-bg-subtle: rgba(255, 213, 79, 0.08); - --color-highlight-bg: rgba(255, 213, 79, 0.3); - --color-highlight-hover-bg: rgba(255, 213, 79, 0.5); - --color-highlight-border: rgba(255, 179, 0, 0.5); - --color-highlight-active-bg: rgba(255, 179, 0, 0.4); - --color-highlight-active-outline: rgba(255, 179, 0, 0.3); - - /* Spacing */ - --space-xs: 0.25rem; - --space-sm: 0.5rem; - --space-md: 1rem; - --space-lg: 1.5rem; - --space-xl: 2rem; - --space-2xl: 3rem; - - /* Typography */ - --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - --font-mono: "SF Mono", SFMono-Regular, ui-monospace, Menlo, monospace; - --text-sm: 0.875rem; - --text-base: 1rem; - --text-lg: 1.125rem; - --text-xl: 1.25rem; - --text-2xl: 1.5rem; - - /* Layout */ - --max-width: 72rem; - --nav-height: 3.5rem; - --radius: 0.375rem; - --shadow: 0 1px 3px rgba(15, 23, 42, 0.08); - --shadow-lg: 0 12px 32px rgba(15, 23, 42, 0.12); -} - -@media (prefers-color-scheme: dark) { - :root { - color-scheme: dark; - --color-bg: #0b1220; - --color-bg-muted: #111a2b; - --color-surface: #152033; - --color-surface-muted: #111a2b; - --color-input-bg: #0f172a; - --color-text: #e5eefc; - --color-text-muted: #99a7bf; - --color-border: #243145; - --color-border-strong: #334155; - --color-primary: #60a5fa; - --color-primary-hover: #3b82f6; - --color-primary-light: rgba(96, 165, 250, 0.16); - --color-success: #34d399; - --color-success-soft: rgba(52, 211, 153, 0.15); - --color-warning: #f59e0b; - --color-warning-soft: rgba(245, 158, 11, 0.16); - --color-danger: #f87171; - --color-danger-hover: #ef4444; - --color-danger-soft: rgba(248, 113, 113, 0.16); - --color-type-bg: rgba(52, 211, 153, 0.14); - --color-type-text: #86efac; - --color-tag-bg: #1e293b; - --color-tag-text: #cbd5e1; - --color-tag-hover-bg: #27364a; - --color-tag-hover-text: #f8fafc; - --color-tag-active-bg: #475569; - --color-tag-active-hover-bg: #64748b; - --color-code-bg: #0f172a; - --color-overlay: rgba(2, 6, 23, 0.4); - --color-focus-ring: rgba(96, 165, 250, 0.24); - --color-status-brainstorm-bg: rgba(139, 92, 246, 0.18); - --color-status-considering-bg: rgba(245, 158, 11, 0.16); - --color-status-developing-bg: rgba(59, 130, 246, 0.18); - --color-status-live-bg: rgba(16, 185, 129, 0.16); - --color-status-abandoned-bg: rgba(148, 163, 184, 0.16); - --color-diff-ins-bg: rgba(52, 211, 153, 0.16); - --color-diff-ins-text: #a7f3d0; - --color-diff-del-bg: rgba(248, 113, 113, 0.16); - --color-diff-del-text: #fecaca; - --color-quote-warning-bg: rgba(245, 158, 11, 0.16); - --color-quote-warning-bg-subtle: rgba(245, 158, 11, 0.12); - --color-highlight-bg: rgba(245, 158, 11, 0.22); - --color-highlight-hover-bg: rgba(245, 158, 11, 0.32); - --color-highlight-border: rgba(245, 158, 11, 0.8); - --color-highlight-active-bg: rgba(245, 158, 11, 0.28); - --color-highlight-active-outline: rgba(245, 158, 11, 0.35); - --shadow: 0 1px 3px rgba(0, 0, 0, 0.35); - --shadow-lg: 0 18px 40px rgba(0, 0, 0, 0.45); - } -} - -/* Reset */ -*, *::before, *::after { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -html { - font-family: var(--font-sans); - font-size: var(--text-base); - line-height: 1.6; - color: var(--color-text); - background-color: var(--color-bg); - -webkit-font-smoothing: antialiased; -} - -body { - min-height: 100vh; - display: flex; - flex-direction: column; - background-color: var(--color-bg); - color: var(--color-text); -} - -a { - color: var(--color-primary); - text-decoration: none; -} - -a:hover { - text-decoration: underline; -} - -img, svg { - max-width: 100%; - display: block; -} - -/* Navigation */ -.site-nav { - background: var(--color-surface); - border-bottom: 1px solid var(--color-border); - height: var(--nav-height); - position: sticky; - top: 0; - z-index: 100; -} - -.site-nav__inner { - max-width: var(--max-width); - margin: 0 auto; - padding: 0 var(--space-lg); - height: 100%; - display: flex; - align-items: center; - justify-content: space-between; -} - -.site-nav__brand { - font-size: var(--text-lg); - font-weight: 700; - color: var(--color-text); - text-decoration: none; - display: flex; - align-items: center; - gap: var(--space-sm); -} - -.site-nav__logo { - width: 28px; - height: 28px; - border-radius: 6px; -} - -.site-nav__brand:hover { - text-decoration: none; - color: var(--color-primary); -} - -.site-nav__links { - display: flex; - align-items: center; - gap: var(--space-lg); - list-style: none; -} - -.site-nav__links a { - color: var(--color-text-muted); - font-size: var(--text-sm); - font-weight: 500; - transition: color 0.15s; -} - -.site-nav__links a:hover { - color: var(--color-text); - text-decoration: none; -} - -.site-nav__user { - display: flex; - align-items: center; - gap: var(--space-sm); - color: var(--color-text-muted); - font-size: var(--text-sm); -} - -/* Main content */ -.main-content { - flex: 1; - max-width: var(--max-width); - margin: 0 auto; - padding: var(--space-xl) var(--space-lg); - width: 100%; -} - -/* Page header */ -.page-header { - margin-bottom: var(--space-xl); - display: flex; - align-items: center; - justify-content: space-between; -} - -.page-header h1 { - font-size: var(--text-2xl); - font-weight: 700; -} - -/* Flash messages */ -.flash { - padding: var(--space-md) var(--space-lg); - border-radius: var(--radius); - margin-bottom: var(--space-lg); - font-size: var(--text-sm); -} - -.flash--notice { - background: var(--color-primary-light); - color: var(--color-primary); - border: 1px solid var(--color-primary); -} - -.flash--alert { - background: var(--color-danger-soft); - color: var(--color-danger); - border: 1px solid var(--color-danger); -} - -/* Buttons */ -.btn { - display: inline-flex; - align-items: center; - gap: var(--space-xs); - padding: var(--space-sm) var(--space-md); - font-size: var(--text-sm); - font-weight: 500; - border-radius: var(--radius); - border: 1px solid transparent; - cursor: pointer; - transition: all 0.15s; - text-decoration: none; - line-height: 1.4; -} - -.btn:hover { - text-decoration: none; -} - -.btn--primary { - background: var(--color-primary); - color: var(--color-text-inverse); -} - -.btn--primary:hover { - background: var(--color-primary-hover); - color: var(--color-text-inverse); -} - -.btn--secondary { - background: var(--color-surface); - color: var(--color-text); - border-color: var(--color-border); -} - -.btn--secondary:hover { - background: var(--color-bg); -} - -.btn--danger { - background: var(--color-danger); - color: var(--color-text-inverse); -} - -.btn--danger:hover { - background: var(--color-danger-hover); - color: var(--color-text-inverse); -} - -/* Cards */ -.card { - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius); - padding: var(--space-lg); - box-shadow: var(--shadow); -} - -/* Status badges */ -.badge { - display: inline-block; - padding: var(--space-xs) var(--space-sm); - font-size: 0.75rem; - font-weight: 600; - border-radius: 9999px; - text-transform: uppercase; - letter-spacing: 0.025em; -} - -.badge--brainstorm { background: var(--color-status-brainstorm-bg); color: var(--color-status-brainstorm); } -.badge--considering { background: var(--color-status-considering-bg); color: var(--color-status-considering); } -.badge--developing { background: var(--color-status-developing-bg); color: var(--color-status-developing); } -.badge--live { background: var(--color-status-live-bg); color: var(--color-status-live); } -.badge--abandoned { background: var(--color-status-abandoned-bg); color: var(--color-status-abandoned); } -.badge--success { background: var(--color-success-soft); color: var(--color-success); } -.badge--warning { background: var(--color-warning-soft); color: var(--color-warning); } -.badge--danger { background: var(--color-danger-soft); color: var(--color-danger); } - -/* Forms */ -.form-group { - margin-bottom: var(--space-lg); -} - -.form-group label { - display: block; - font-size: var(--text-sm); - font-weight: 500; - margin-bottom: var(--space-xs); -} - -.form-group input[type="text"], -.form-group input[type="email"], -.form-group input[type="url"], -.form-group textarea, -.form-group select { - width: 100%; - padding: var(--space-sm) var(--space-md); - font-size: var(--text-base); - font-family: var(--font-sans); - border: 1px solid var(--color-border); - border-radius: var(--radius); - background: var(--color-input-bg); - color: var(--color-text); - transition: border-color 0.15s; -} - -.form-group input:focus, -.form-group textarea:focus, -.form-group select:focus { - outline: none; - border-color: var(--color-primary); - box-shadow: 0 0 0 3px var(--color-focus-ring); -} - -.form-group textarea { - min-height: 8rem; - resize: vertical; -} - -/* Empty states */ -.empty-state { - text-align: center; - padding: var(--space-2xl); - color: var(--color-text-muted); -} - -.empty-state p { - margin-bottom: var(--space-md); -} - -/* Sign in page */ -.sign-in-page { - display: flex; - align-items: center; - justify-content: center; - min-height: 60vh; -} - -.sign-in-card { - width: 100%; - max-width: 24rem; - text-align: center; -} - -.sign-in-card h1 { - font-size: var(--text-2xl); - margin-bottom: var(--space-xs); -} - -.sign-in-form { - margin-top: var(--space-sm); - text-align: left; -} - -.sign-in-submit { - width: 100%; - justify-content: center; -} - -.sign-in-warning { - margin-top: var(--space-md); - padding: var(--space-sm) 0.75rem; - border-radius: var(--radius); - border: 1px solid var(--color-warning); - background: var(--color-warning-soft); - color: var(--color-text); - font-size: 0.8rem; - text-align: left; -} - -/* Status filters */ -.status-filters { - display: flex; - gap: var(--space-sm); - margin-bottom: var(--space-xl); - flex-wrap: wrap; -} - -.status-filter { - padding: var(--space-xs) var(--space-md); - font-size: var(--text-sm); - font-weight: 500; - border-radius: 9999px; - color: var(--color-text-muted); - border: 1px solid var(--color-border); - text-decoration: none; - transition: all 0.15s; -} - -.status-filter:hover { - text-decoration: none; - border-color: var(--color-text-muted); -} - -.status-filter--active { - background: var(--color-primary); - color: var(--color-text-inverse); - border-color: var(--color-primary); -} - -/* Plans list */ -.plans-list { - display: flex; - flex-direction: column; - gap: var(--space-md); -} - -.plans-list__header { - display: flex; - align-items: center; - gap: var(--space-sm); - margin-bottom: var(--space-xs); -} - -.plans-list__title { - font-size: var(--text-lg); - font-weight: 600; - color: var(--color-text); -} - -.plans-list__title:hover { - color: var(--color-primary); -} - -.plans-list__meta { - margin-top: var(--space-xs); -} - -/* Plan form */ -.plan-form__actions { - display: flex; - gap: var(--space-sm); - margin-top: var(--space-md); -} - -/* Plan content */ -.plan-content pre { - font-family: var(--font-mono); - font-size: var(--text-sm); - line-height: 1.7; - white-space: pre-wrap; - word-wrap: break-word; -} - -/* Page header actions */ -.page-header__actions { - display: flex; - gap: var(--space-sm); -} - -/* Version list */ -.versions-list { - display: flex; - flex-direction: column; - gap: var(--space-md); -} - -.versions-list__header { - display: flex; - align-items: center; - gap: var(--space-sm); - margin-bottom: var(--space-xs); -} - -.versions-list__link { - font-weight: 600; - font-size: var(--text-lg); -} - -/* Diff view */ -.diff-view { - font-family: var(--font-mono); - font-size: var(--text-sm); - line-height: 1.6; - margin-top: var(--space-md); - overflow-x: auto; -} - -.diff-view ul { - list-style: none; - padding: 0; -} - -.diff-view li { - padding: 1px var(--space-sm); - white-space: pre-wrap; -} - -.diff-view li.ins { - background: var(--color-diff-ins-bg); - color: var(--color-diff-ins-text); -} - -.diff-view li.del { - background: var(--color-diff-del-bg); - color: var(--color-diff-del-text); -} - -/* Utility */ -.text-muted { color: var(--color-text-muted); } -.text-sm { font-size: var(--text-sm); } -.mt-md { margin-top: var(--space-md); } -.mb-md { margin-bottom: var(--space-md); } - -/* Plan view toggle */ -.plan-view-toggle { - display: flex; - gap: var(--space-xs); - margin-bottom: var(--space-md); - padding-bottom: var(--space-md); - border-bottom: 1px solid var(--color-border); -} - -/* Rendered markdown */ -.markdown-rendered h1, -.markdown-rendered h2, -.markdown-rendered h3, -.markdown-rendered h4, -.markdown-rendered h5, -.markdown-rendered h6 { - margin-top: var(--space-xl); - margin-bottom: var(--space-md); - font-weight: 700; - line-height: 1.3; -} - -.markdown-rendered h1 { font-size: var(--text-2xl); } -.markdown-rendered h2 { font-size: var(--text-xl); } -.markdown-rendered h3 { font-size: var(--text-lg); } -.markdown-rendered h1:first-child, -.markdown-rendered h2:first-child, -.markdown-rendered h3:first-child { margin-top: 0; } - -.markdown-rendered p { - margin-bottom: var(--space-md); - line-height: 1.7; -} - -.markdown-rendered ul, -.markdown-rendered ol { - margin-bottom: var(--space-md); - padding-left: var(--space-xl); -} - -.markdown-rendered li { - margin-bottom: var(--space-xs); - line-height: 1.7; -} - -.markdown-rendered pre { - background: var(--color-code-bg); - border: 1px solid var(--color-border); - border-radius: var(--radius); - 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; -} - -.markdown-rendered code { - font-family: var(--font-mono); - font-size: 0.875em; -} - -.markdown-rendered :not(pre) > code { - background: var(--color-code-bg); - padding: 0.15em 0.35em; - border-radius: 3px; -} - -.markdown-rendered blockquote { - border-left: 3px solid var(--color-border); - padding-left: var(--space-md); - color: var(--color-text-muted); - margin-bottom: var(--space-md); -} - -.markdown-rendered table { - width: 100%; - border-collapse: collapse; - margin-bottom: var(--space-md); -} - -.markdown-rendered th, -.markdown-rendered td { - border: 1px solid var(--color-border); - padding: var(--space-sm) var(--space-md); - text-align: left; -} - -.markdown-rendered th { - background: var(--color-bg); - font-weight: 600; -} - -.markdown-rendered hr { - border: none; - border-top: 1px solid var(--color-border); - margin: var(--space-xl) 0; -} - -.markdown-rendered a { - color: var(--color-primary); -} - -.markdown-rendered img { - max-width: 100%; - border-radius: var(--radius); -} - -/* Comment popover (appears on text selection) */ -.comment-popover { - position: absolute; - z-index: 10; - animation: popover-fade-in 0.15s ease-out; -} - -@keyframes popover-fade-in { - from { opacity: 0; transform: translateY(-4px); } - to { opacity: 1; transform: translateY(0); } -} - -/* Anchor text highlights */ -.anchor-highlight { - background: var(--color-highlight-bg); - border-bottom: 2px solid var(--color-highlight-border); - cursor: pointer; - transition: background 0.2s; -} - -.anchor-highlight:hover { - background: var(--color-highlight-hover-bg); -} - -.anchor-highlight--active { - background: var(--color-highlight-active-bg); - border-bottom: 2px solid var(--color-warning); - outline: 2px solid var(--color-highlight-active-outline); - outline-offset: 1px; -} - -/* Comment form anchor quote */ -.comment-form__anchor { - margin-bottom: var(--space-sm); -} - -.comment-form__quote { - border-left: 3px solid var(--color-warning); - padding: var(--space-xs) var(--space-sm); - margin: var(--space-xs) 0 0 0; - font-size: var(--text-sm); - color: var(--color-text-muted); - background: var(--color-quote-warning-bg); - border-radius: 0 var(--radius) var(--radius) 0; -} - -.comment-form__actions { - display: flex; - gap: var(--space-sm); -} - -/* Thread anchor quote */ -.comment-thread__anchor-quote { - border-left: 3px solid var(--color-warning); - padding: var(--space-xs) var(--space-sm); - margin: var(--space-xs) 0 0 0; - font-size: var(--text-sm); - color: var(--color-text-muted); - background: var(--color-quote-warning-bg-subtle); - border-radius: 0 var(--radius) var(--radius) 0; -} - -/* Plan layout (content + sidebar) */ -.plan-layout { - position: relative; -} - -.plan-layout__content { - max-width: 60%; - position: relative; -} - -.plan-layout__sidebar { - position: absolute; - top: 0; - right: 0; - width: 22rem; -} - -@media (max-width: 900px) { - .plan-layout__content { - max-width: 100%; - } - - .plan-layout__sidebar { - position: static; - width: 100%; - margin-top: var(--space-lg); - } -} -.sidebar-heading { - font-size: var(--text-lg); - font-weight: 700; - margin-bottom: var(--space-md); -} - -/* Comment tabs */ -.comment-tabs__nav { - display: flex; - gap: 0; - border-bottom: 2px solid var(--color-border); - margin-bottom: var(--space-md); -} - -.comment-tab { - padding: var(--space-xs) var(--space-md); - font-size: var(--text-sm); - font-weight: 600; - color: var(--color-text-muted); - background: none; - border: none; - border-bottom: 2px solid transparent; - margin-bottom: -2px; - cursor: pointer; -} - -.comment-tab:hover { - color: var(--color-text); -} - -.comment-tab--active { - color: var(--color-primary); - border-bottom-color: var(--color-primary); -} - -.comment-tab__count { - background: var(--color-border); - color: var(--color-text-muted); - font-size: 0.7rem; - padding: 1px 6px; - border-radius: 10px; - margin-left: 4px; -} - -.comment-tab--active .comment-tab__count { - background: var(--color-primary); - color: var(--color-text-inverse); -} - -/* Comment form */ -.comment-form { - margin-bottom: var(--space-md); - padding: var(--space-md); -} - -.comment-form .form-group { - margin-bottom: var(--space-sm); -} - -.comment-form textarea { - width: 100%; - padding: var(--space-sm); - font-family: var(--font-sans); - font-size: var(--text-sm); - border: 1px solid var(--color-border); - border-radius: var(--radius); - background: var(--color-input-bg); - color: var(--color-text); - resize: vertical; - min-height: 4rem; -} - -.comment-form textarea:focus { - outline: none; - border-color: var(--color-primary); - box-shadow: 0 0 0 3px var(--color-focus-ring); -} - -/* Comment threads list */ -.comment-threads-list { - display: flex; - flex-direction: column; - gap: var(--space-md); -} - -/* Comment thread */ -.comment-thread { - padding: var(--space-md); -} - -.comment-thread__header { - margin-bottom: var(--space-sm); -} - -.comment-thread__meta { - display: flex; - gap: var(--space-xs); - flex-wrap: wrap; - align-items: center; -} - -.comment-thread__comments { - display: flex; - flex-direction: column; - gap: var(--space-sm); -} - -/* Individual comment */ -.comment { - padding: var(--space-sm) 0; - border-bottom: 1px solid var(--color-border); -} - -.comment:last-child { - border-bottom: none; -} - -.comment__header { - margin-bottom: var(--space-xs); -} - -.comment__body .markdown-rendered { - font-size: var(--text-sm); -} - -.comment__body .markdown-rendered p:last-child { - margin-bottom: 0; -} - -/* Comment thread reply */ -.comment-thread__reply { - margin-top: var(--space-sm); - padding-top: var(--space-sm); - border-top: 1px solid var(--color-border); -} - -.comment-thread__reply textarea { - width: 100%; - padding: var(--space-sm); - font-family: var(--font-sans); - font-size: var(--text-sm); - border: 1px solid var(--color-border); - border-radius: var(--radius); - background: var(--color-input-bg); - color: var(--color-text); - resize: vertical; - min-height: 3rem; -} - -.comment-thread__reply textarea:focus { - outline: none; - border-color: var(--color-primary); - box-shadow: 0 0 0 3px var(--color-focus-ring); -} - -.comment-thread__reply .form-group { - margin-bottom: var(--space-xs); -} - -/* Comment thread actions */ -.comment-thread__actions { - display: flex; - gap: var(--space-xs); - margin-top: var(--space-sm); - padding-top: var(--space-sm); - border-top: 1px solid var(--color-border); -} - -/* Small button variant */ -.btn--sm { - padding: var(--space-xs) var(--space-sm); - font-size: 0.75rem; -} - -/* Token reveal */ -.token-reveal { - background: var(--color-success); - color: var(--color-text-inverse); - padding: var(--space-lg); - border-radius: var(--radius); - margin-bottom: var(--space-lg); -} - -.token-reveal h3 { - margin: 0 0 var(--space-xs) 0; -} - -.token-reveal p { - margin: 0 0 var(--space-sm) 0; - opacity: 0.9; -} - -.token-reveal__value { - background: var(--color-overlay); - padding: var(--space-sm) var(--space-md); - border-radius: var(--radius); - font-family: var(--font-mono); - font-size: var(--text-sm); - word-break: break-all; - user-select: all; -} - -.token-reveal__value code { - color: var(--color-text-inverse); - background: none; -} - -/* Data table */ -.data-table { - width: 100%; - border-collapse: collapse; -} - -.data-table th, -.data-table td { - padding: var(--space-sm) var(--space-md); - text-align: left; - border-bottom: 1px solid var(--color-border); -} - -.data-table th { - font-weight: 600; - color: var(--color-text-muted); - font-size: var(--text-sm); - background: var(--color-surface-muted); -} - -.data-table__row--muted td { - opacity: 0.5; -} - -/* Form inline */ -.form-inline { - display: flex; - align-items: flex-end; - gap: var(--space-md); - flex-wrap: wrap; -} - -.form-inline .form-group { - flex: 1; - min-width: 200px; -} - -/* Dropdown */ -.dropdown { - position: relative; - display: inline-block; -} - -.dropdown__menu { - position: absolute; - right: 0; - top: 100%; - margin-top: var(--space-xs); - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius); - box-shadow: var(--shadow-lg); - min-width: 200px; - z-index: 100; -} - -.dropdown__item { - display: block; - width: 100%; - padding: var(--space-sm) var(--space-md); - text-align: left; - background: none; - border: none; - cursor: pointer; - font-size: var(--text-sm); - color: var(--color-text); -} - -.dropdown__item:hover { - background: var(--color-bg-muted); -} - -/* Page header */ -.page-header__subtitle { - color: var(--color-text-muted); - margin-top: var(--space-xs); -} diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index da428b86..2109d793 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -15,7 +15,13 @@ <%= coplan_favicon_tag %> - <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> + + + + <%# Same tracked stylesheet as the engine layout — if they differ, Turbo + force-reloads on every sign-in/out crossing (and that async reload + races the next navigation). The engine sheet styles these pages. %> + <%= stylesheet_link_tag "coplan/application", "data-turbo-track": "reload" %> <%= javascript_importmap_tags %> diff --git a/config/initializers/coplan.rb b/config/initializers/coplan.rb index e717496e..7c1b2f82 100644 --- a/config/initializers/coplan.rb +++ b/config/initializers/coplan.rb @@ -25,6 +25,21 @@ # PeopleApi.search(query).map { |p| { id: p.id, name: p.name, email: p.email } } # } + # Optional: enrich profile pages from the same directory. Values present + # override local coplan_users columns; :profile_url adds a "view in + # directory" link out to the canonical people page. Exceptions degrade to + # the minimal local profile, so a slow/flaky directory can't break pages + # (cache inside the lambda if lookups are expensive). + # config.directory_profile = ->(user) { + # person = PeopleApi.lookup(email: user.email) + # { + # avatar_url: person.photo_url, + # title: person.job_title, + # team: person.org_name, + # profile_url: person.canonical_url + # } + # } + config.notification_handler = ->(event, payload) { case event when :comment_created diff --git a/db/migrate/20260717205624_replace_status_with_visibility_and_archival.co_plan.rb b/db/migrate/20260717205624_replace_status_with_visibility_and_archival.co_plan.rb new file mode 100644 index 00000000..86404010 --- /dev/null +++ b/db/migrate/20260717205624_replace_status_with_visibility_and_archival.co_plan.rb @@ -0,0 +1,74 @@ +# This migration comes from co_plan (originally 20260717000000) +class ReplaceStatusWithVisibilityAndArchival < ActiveRecord::Migration[8.1] + # Migration-local model stubs so this migration never breaks as app models + # evolve. Only the columns touched here are relied upon. + class MigrationPlan < ActiveRecord::Base + self.table_name = "coplan_plans" + end + + class MigrationTag < ActiveRecord::Base + self.table_name = "coplan_tags" + before_create { self.id ||= SecureRandom.uuid_v7 } + end + + class MigrationPlanTag < ActiveRecord::Base + self.table_name = "coplan_plan_tags" + before_create { self.id ||= SecureRandom.uuid_v7 } + end + + def up + # Plans are shared by default — private drafts are the opt-in exception, + # so "published" is the column default for new rows. + add_column :coplan_plans, :visibility, :string, null: false, default: "published" + add_column :coplan_plans, :archived_at, :datetime + add_index :coplan_plans, :visibility + add_index :coplan_plans, :archived_at + + # brainstorm was "private draft"; everything else was org-visible. + # abandoned was the de-facto archive. developing/live carried lifecycle + # info some plans genuinely have, so it is preserved as tags rather than + # destroyed. updated_at is deliberately left untouched — archival state + # is derived data, not user activity. + execute <<~SQL + UPDATE coplan_plans + SET visibility = CASE WHEN status = 'brainstorm' THEN 'draft' ELSE 'published' END, + archived_at = CASE WHEN status = 'abandoned' THEN updated_at ELSE NULL END + SQL + + %w[developing live].each do |lifecycle| + plan_ids = MigrationPlan.where(status: lifecycle).pluck(:id) + next if plan_ids.empty? + + tag = MigrationTag.find_or_create_by!(name: lifecycle) + existing = MigrationPlanTag.where(tag_id: tag.id, plan_id: plan_ids).pluck(:plan_id) + (plan_ids - existing).each do |plan_id| + MigrationPlanTag.create!(plan_id: plan_id, tag_id: tag.id) + end + end + + remove_index :coplan_plans, :status + remove_column :coplan_plans, :status + end + + def down + add_column :coplan_plans, :status, :string, null: false, default: "brainstorm" + add_index :coplan_plans, :status + + # Best-effort reversal: draft→brainstorm, archived→abandoned, otherwise + # considering (the developing/live distinction lives in tags and is not + # re-derived here). + execute <<~SQL + UPDATE coplan_plans + SET status = CASE + WHEN visibility = 'draft' THEN 'brainstorm' + WHEN archived_at IS NOT NULL THEN 'abandoned' + ELSE 'considering' + END + SQL + + remove_index :coplan_plans, :archived_at + remove_index :coplan_plans, :visibility + remove_column :coplan_plans, :archived_at + remove_column :coplan_plans, :visibility + end +end diff --git a/db/migrate/20260717211002_create_libraries_and_placements.co_plan.rb b/db/migrate/20260717211002_create_libraries_and_placements.co_plan.rb new file mode 100644 index 00000000..52b39512 --- /dev/null +++ b/db/migrate/20260717211002_create_libraries_and_placements.co_plan.rb @@ -0,0 +1,125 @@ +# This migration comes from co_plan (originally 20260717010000) +class CreateLibrariesAndPlacements < ActiveRecord::Migration[8.1] + class MigrationFolder < ActiveRecord::Base + self.table_name = "coplan_folders" + end + + class MigrationLibrary < ActiveRecord::Base + self.table_name = "coplan_libraries" + before_create { self.id ||= SecureRandom.uuid_v7 } + end + + class MigrationPlacement < ActiveRecord::Base + self.table_name = "coplan_plan_placements" + before_create { self.id ||= SecureRandom.uuid_v7 } + end + + class MigrationPlan < ActiveRecord::Base + self.table_name = "coplan_plans" + end + + def up + # A library is the owner-shaped container for a folder tree. Owner is + # polymorphic: users in v1, teams (or other group types) later. + create_table :coplan_libraries, id: { type: :string, limit: 36 } do |t| + t.string :owner_type, null: false + t.string :owner_id, limit: 36, null: false + t.string :name, null: false, default: "Library" + t.timestamps + + # One library per owner (v1). Relax deliberately if/when multiple + # libraries per owner become a real product need. + t.index [ :owner_type, :owner_id ], unique: true + end + + add_column :coplan_folders, :library_id, :string, limit: 36 + add_index :coplan_folders, :library_id + + # A placement shelves a plan in a folder. It is the viewer's organization + # of the plan, not a property of the plan itself: the same plan can sit + # in many libraries at once. library_id is denormalized from the folder + # so "one spot per library" is enforceable with a unique index. + create_table :coplan_plan_placements, id: { type: :string, limit: 36 } do |t| + t.string :plan_id, limit: 36, null: false + t.string :folder_id, limit: 36, null: false + t.string :library_id, limit: 36, null: false + t.string :placed_by_user_id, limit: 36 + t.timestamps + + t.index [ :plan_id, :library_id ], unique: true + t.index :folder_id + t.index :library_id + end + + # The org-global folder design (#145) never reached production, so this + # backfill only matters for development databases: each folder joins its + # creator's library, creator-less folders are dropped (dev debris), and + # plans' single folder_id becomes a placement. + MigrationFolder.reset_column_information + MigrationFolder.where(created_by_user_id: nil).delete_all + MigrationFolder.find_each do |folder| + library = MigrationLibrary.find_or_create_by!( + owner_type: "CoPlan::User", + owner_id: folder.created_by_user_id + ) + folder.update_columns(library_id: library.id) + end + + MigrationPlan.where.not(folder_id: nil).find_each do |plan| + folder = MigrationFolder.find_by(id: plan.folder_id) + next unless folder&.library_id + + MigrationPlacement.create!( + plan_id: plan.id, + folder_id: folder.id, + library_id: folder.library_id, + placed_by_user_id: plan.created_by_user_id + ) + end + + change_column_null :coplan_folders, :library_id, false + + # Folder names are unique per parent within a library now (two people + # can both have a root "Projects" folder). The parent_id FK needs an + # index leading with parent_id, which the old composite provided — add + # a plain one before MySQL will let the composite go. + add_index :coplan_folders, :parent_id + remove_index :coplan_folders, column: [ :parent_id, :name ], unique: true + add_index :coplan_folders, [ :library_id, :parent_id, :name ], unique: true + + remove_foreign_key :coplan_plans, :coplan_folders, column: :folder_id + remove_index :coplan_plans, :folder_id + remove_column :coplan_plans, :folder_id + + add_foreign_key :coplan_folders, :coplan_libraries, column: :library_id + add_foreign_key :coplan_plan_placements, :coplan_plans, column: :plan_id + add_foreign_key :coplan_plan_placements, :coplan_folders, column: :folder_id + add_foreign_key :coplan_plan_placements, :coplan_libraries, column: :library_id + add_foreign_key :coplan_plan_placements, :coplan_users, column: :placed_by_user_id + end + + def down + add_column :coplan_plans, :folder_id, :string, limit: 36 + add_index :coplan_plans, :folder_id + add_foreign_key :coplan_plans, :coplan_folders, column: :folder_id + + # Restore each plan's author-library placement as its folder, then drop + # the placement machinery. + MigrationPlacement.reset_column_information + MigrationPlacement.find_each do |placement| + plan = MigrationPlan.find_by(id: placement.plan_id) + next unless plan && plan.created_by_user_id == placement.placed_by_user_id + + plan.update_columns(folder_id: placement.folder_id) + end + + remove_index :coplan_folders, column: [ :library_id, :parent_id, :name ], unique: true + add_index :coplan_folders, [ :parent_id, :name ], unique: true + remove_index :coplan_folders, :parent_id + remove_foreign_key :coplan_folders, :coplan_libraries, column: :library_id + remove_column :coplan_folders, :library_id + + drop_table :coplan_plan_placements + drop_table :coplan_libraries + end +end diff --git a/db/migrate/20260718171748_add_icon_to_coplan_plan_types.co_plan.rb b/db/migrate/20260718171748_add_icon_to_coplan_plan_types.co_plan.rb new file mode 100644 index 00000000..025f8afb --- /dev/null +++ b/db/migrate/20260718171748_add_icon_to_coplan_plan_types.co_plan.rb @@ -0,0 +1,9 @@ +# This migration comes from co_plan (originally 20260718000000) +class AddIconToCoplanPlanTypes < ActiveRecord::Migration[8.0] + def change + # Named icon from the built-in set (PlansHelper::PLAN_TYPE_ICONS + # renders the SVG) — a name, not raw markup, so installs can brand + # their document types without injecting arbitrary SVG. + add_column :coplan_plan_types, :icon, :string, limit: 50 + end +end diff --git a/db/migrate/20260719165429_add_workspace_scale_indexes.co_plan.rb b/db/migrate/20260719165429_add_workspace_scale_indexes.co_plan.rb new file mode 100644 index 00000000..f5f3eae9 --- /dev/null +++ b/db/migrate/20260719165429_add_workspace_scale_indexes.co_plan.rb @@ -0,0 +1,21 @@ +# This migration comes from co_plan (originally 20260719000000) +class AddWorkspaceScaleIndexes < ActiveRecord::Migration[8.1] + # The workspace's hot paths at scale (50k–1M+ plans): + # + # * Sidebar folder counts: placements filtered by library, grouped by + # folder — a covering index answers it without touching the table. + # * "Mine" lists: created_by_user + ORDER BY updated_at — a composite + # index sorts without a filesort. + # * Org-wide lists/feeds: visibility + ORDER BY updated_at, same idea. + # + # The single-column indexes these overlap (library_id, visibility) stay: + # other queries (uniqueness upserts, admin filters) still want them. + def change + add_index :coplan_plan_placements, [ :library_id, :folder_id, :plan_id ], + name: "index_coplan_placements_covering_folder_counts" + add_index :coplan_plans, [ :created_by_user_id, :updated_at ], + name: "index_coplan_plans_on_author_and_updated_at" + add_index :coplan_plans, [ :visibility, :updated_at ], + name: "index_coplan_plans_on_visibility_and_updated_at" + end +end diff --git a/db/schema.rb b/db/schema.rb index 5838e594..e83c344e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_07_16_154924) do +ActiveRecord::Schema[8.1].define(version: 2026_07_19_165429) do create_table "active_admin_comments", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "author_id" t.string "author_type" @@ -139,11 +139,23 @@ create_table "coplan_folders", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "created_at", null: false t.string "created_by_user_id", limit: 36 + t.string "library_id", limit: 36, null: false t.string "name", null: false t.string "parent_id", limit: 36 t.datetime "updated_at", null: false t.index ["created_by_user_id"], name: "index_coplan_folders_on_created_by_user_id" - t.index ["parent_id", "name"], name: "index_coplan_folders_on_parent_id_and_name", unique: true + t.index ["library_id", "parent_id", "name"], name: "index_coplan_folders_on_library_id_and_parent_id_and_name", unique: true + t.index ["library_id"], name: "index_coplan_folders_on_library_id" + t.index ["parent_id"], name: "index_coplan_folders_on_parent_id" + end + + create_table "coplan_libraries", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "name", default: "Library", null: false + t.string "owner_id", limit: 36, null: false + t.string "owner_type", null: false + t.datetime "updated_at", null: false + t.index ["owner_type", "owner_id"], name: "index_coplan_libraries_on_owner_type_and_owner_id", unique: true end create_table "coplan_notifications", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -191,6 +203,20 @@ t.index ["plan_id"], name: "index_coplan_plan_events_on_plan_id" end + create_table "coplan_plan_placements", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "folder_id", limit: 36, null: false + t.string "library_id", limit: 36, null: false + t.string "placed_by_user_id", limit: 36 + t.string "plan_id", limit: 36, null: false + t.datetime "updated_at", null: false + t.index ["folder_id"], name: "index_coplan_plan_placements_on_folder_id" + t.index ["library_id", "folder_id", "plan_id"], name: "index_coplan_placements_covering_folder_counts" + t.index ["library_id"], name: "index_coplan_plan_placements_on_library_id" + t.index ["placed_by_user_id"], name: "fk_rails_ef17324b42" + t.index ["plan_id", "library_id"], name: "index_coplan_plan_placements_on_plan_id_and_library_id", unique: true + end + create_table "coplan_plan_tags", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "created_at", null: false t.string "plan_id", limit: 36, null: false @@ -204,6 +230,7 @@ t.datetime "created_at", null: false t.json "default_tags" t.text "description" + t.string "icon", limit: 50 t.json "metadata" t.string "name", null: false t.text "template_content" @@ -244,27 +271,29 @@ end create_table "coplan_plans", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "archived_at" t.datetime "created_at", null: false t.string "created_by_user_id", limit: 36, null: false t.string "current_plan_version_id", limit: 36 t.integer "current_revision", default: 0, null: false - t.string "folder_id", limit: 36 t.json "metadata" t.string "plan_type_id", limit: 36 t.text "search_text", size: :medium - t.string "status", default: "brainstorm", null: false t.text "summary" t.string "summary_content_sha256", limit: 64 t.datetime "summary_generated_at" t.string "title", null: false t.datetime "updated_at", null: false + t.string "visibility", default: "published", null: false + t.index ["archived_at"], name: "index_coplan_plans_on_archived_at" + t.index ["created_by_user_id", "updated_at"], name: "index_coplan_plans_on_author_and_updated_at" t.index ["created_by_user_id"], name: "index_coplan_plans_on_created_by_user_id" t.index ["current_plan_version_id"], name: "fk_rails_c401577583" - t.index ["folder_id"], name: "index_coplan_plans_on_folder_id" t.index ["plan_type_id"], name: "index_coplan_plans_on_plan_type_id" t.index ["search_text"], name: "index_coplan_plans_on_search_text", type: :fulltext - t.index ["status"], name: "index_coplan_plans_on_status" t.index ["updated_at"], name: "index_coplan_plans_on_updated_at" + t.index ["visibility", "updated_at"], name: "index_coplan_plans_on_visibility_and_updated_at" + t.index ["visibility"], name: "index_coplan_plans_on_visibility" end create_table "coplan_references", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -346,6 +375,7 @@ add_foreign_key "coplan_edit_sessions", "coplan_plan_versions", column: "plan_version_id" add_foreign_key "coplan_edit_sessions", "coplan_plans", column: "plan_id" add_foreign_key "coplan_folders", "coplan_folders", column: "parent_id" + add_foreign_key "coplan_folders", "coplan_libraries", column: "library_id" add_foreign_key "coplan_folders", "coplan_users", column: "created_by_user_id" add_foreign_key "coplan_notifications", "coplan_comment_threads", column: "comment_thread_id" add_foreign_key "coplan_notifications", "coplan_comments", column: "comment_id" @@ -354,12 +384,15 @@ add_foreign_key "coplan_plan_collaborators", "coplan_plans", column: "plan_id" add_foreign_key "coplan_plan_collaborators", "coplan_users", column: "added_by_user_id" add_foreign_key "coplan_plan_collaborators", "coplan_users", column: "user_id" + add_foreign_key "coplan_plan_placements", "coplan_folders", column: "folder_id" + add_foreign_key "coplan_plan_placements", "coplan_libraries", column: "library_id" + add_foreign_key "coplan_plan_placements", "coplan_plans", column: "plan_id" + add_foreign_key "coplan_plan_placements", "coplan_users", column: "placed_by_user_id" add_foreign_key "coplan_plan_tags", "coplan_plans", column: "plan_id" add_foreign_key "coplan_plan_tags", "coplan_tags", column: "tag_id" add_foreign_key "coplan_plan_versions", "coplan_plans", column: "plan_id" add_foreign_key "coplan_plan_viewers", "coplan_plans", column: "plan_id" add_foreign_key "coplan_plan_viewers", "coplan_users", column: "user_id" - add_foreign_key "coplan_plans", "coplan_folders", column: "folder_id" add_foreign_key "coplan_plans", "coplan_plan_types", column: "plan_type_id" add_foreign_key "coplan_plans", "coplan_plan_versions", column: "current_plan_version_id" add_foreign_key "coplan_plans", "coplan_users", column: "created_by_user_id" diff --git a/db/seeds.rb b/db/seeds.rb index 451f3473..4dd3442f 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -71,21 +71,24 @@ end puts "Seeding plan types..." +# Icons come from the built-in named set (CoPlan::PlansHelper::PLAN_TYPE_ICONS) +# so each install can brand its document types. general = CoPlan::PlanType.find_or_create_by!(name: "General") do |pt| pt.description = "General-purpose plan" end +general.update!(icon: "file-text") if general.icon.blank? CoPlan::PlanType.find_or_create_by!(name: "RFC") do |pt| pt.description = "Request for Comments — propose a significant change for team review" pt.default_tags = ["rfc"] -end +end.tap { |pt| pt.update!(icon: "scroll") if pt.icon.blank? } CoPlan::PlanType.find_or_create_by!(name: "Design Doc") do |pt| pt.description = "Technical design document for a new system or feature" pt.default_tags = ["design"] -end +end.tap { |pt| pt.update!(icon: "compass") if pt.icon.blank? } CoPlan::PlanType.find_or_create_by!(name: "ADR") do |pt| pt.description = "Architecture Decision Record — document a key technical decision" pt.default_tags = ["adr"] -end +end.tap { |pt| pt.update!(icon: "scale") if pt.icon.blank? } # Backfill any plans without a plan type CoPlan::Plan.where(plan_type_id: nil).update_all(plan_type_id: general.id) diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index aaa8f4b0..e98e73b9 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -39,8 +39,6 @@ --color-focus-ring: rgba(37, 99, 235, 0.12); /* Status colors */ - --color-status-brainstorm: #8b5cf6; - --color-status-brainstorm-bg: #ede9fe; --color-status-considering: #f59e0b; --color-status-considering-bg: #fef3c7; --color-status-developing: #3b82f6; @@ -91,11 +89,26 @@ /* Layout */ --max-width: 72rem; --nav-height: 3.5rem; - --radius: 0.375rem; + --radius: 0.625rem; + --radius-lg: 0.875rem; --shadow: 0 1px 3px rgba(15, 23, 42, 0.08); --shadow-lg: 0 12px 32px rgba(15, 23, 42, 0.12); --shadow-top: 0 -2px 8px rgba(15, 23, 42, 0.05); --shadow-pop: 0 2px 6px rgba(15, 23, 42, 0.1); + + /* Liquid glass: a soft gradient mesh behind everything, translucent + blurred panels on top. Panels use --glass-*; the mesh spots are + --mesh-*. Rows and other high-count elements get the cheaper + --surface-translucent (no backdrop blur). */ + --mesh-1: rgba(59, 130, 246, 0.14); + --mesh-2: rgba(168, 85, 247, 0.11); + --mesh-3: rgba(20, 184, 166, 0.11); + --glass-bg: rgba(255, 255, 255, 0.55); + --glass-bg-strong: rgba(255, 255, 255, 0.72); + --glass-edge: rgba(255, 255, 255, 0.75); + --glass-blur: blur(16px) saturate(1.6); + --shadow-glass: inset 0 1px 0 rgba(255, 255, 255, 0.7), 0 1px 2px rgba(15, 23, 42, 0.04), 0 10px 30px rgba(15, 23, 42, 0.08); + --surface-translucent: rgba(255, 255, 255, 0.6); } @media (prefers-color-scheme: dark) { @@ -133,7 +146,6 @@ --color-code-bg: #0f172a; --color-overlay: rgba(2, 6, 23, 0.4); --color-focus-ring: rgba(96, 165, 250, 0.24); - --color-status-brainstorm-bg: rgba(139, 92, 246, 0.18); --color-status-considering-bg: rgba(245, 158, 11, 0.16); --color-status-developing-bg: rgba(59, 130, 246, 0.18); --color-status-live-bg: rgba(16, 185, 129, 0.16); @@ -163,6 +175,15 @@ --shadow-lg: 0 18px 40px rgba(0, 0, 0, 0.45); --shadow-top: 0 -2px 10px rgba(0, 0, 0, 0.3); --shadow-pop: 0 6px 16px rgba(0, 0, 0, 0.35); + --mesh-1: rgba(59, 130, 246, 0.2); + --mesh-2: rgba(139, 92, 246, 0.16); + --mesh-3: rgba(6, 182, 212, 0.13); + --glass-bg: rgba(15, 23, 42, 0.45); + --glass-bg-strong: rgba(17, 26, 43, 0.66); + --glass-edge: rgba(148, 163, 184, 0.18); + --glass-blur: blur(16px) saturate(1.4); + --shadow-glass: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 1px 2px rgba(0, 0, 0, 0.3), 0 12px 32px rgba(0, 0, 0, 0.4); + --surface-translucent: rgba(21, 32, 51, 0.55); } } @@ -201,7 +222,6 @@ --color-code-bg: #0f172a; --color-overlay: rgba(2, 6, 23, 0.4); --color-focus-ring: rgba(96, 165, 250, 0.24); - --color-status-brainstorm-bg: rgba(139, 92, 246, 0.18); --color-status-considering-bg: rgba(245, 158, 11, 0.16); --color-status-developing-bg: rgba(59, 130, 246, 0.18); --color-status-live-bg: rgba(16, 185, 129, 0.16); @@ -231,6 +251,15 @@ --shadow-lg: 0 18px 40px rgba(0, 0, 0, 0.45); --shadow-top: 0 -2px 10px rgba(0, 0, 0, 0.3); --shadow-pop: 0 6px 16px rgba(0, 0, 0, 0.35); + --mesh-1: rgba(59, 130, 246, 0.2); + --mesh-2: rgba(139, 92, 246, 0.16); + --mesh-3: rgba(6, 182, 212, 0.13); + --glass-bg: rgba(15, 23, 42, 0.45); + --glass-bg-strong: rgba(17, 26, 43, 0.66); + --glass-edge: rgba(148, 163, 184, 0.18); + --glass-blur: blur(16px) saturate(1.4); + --shadow-glass: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 1px 2px rgba(0, 0, 0, 0.3), 0 12px 32px rgba(0, 0, 0, 0.4); + --surface-translucent: rgba(21, 32, 51, 0.55); } /* Forced light theme — overrides system dark preference */ @@ -259,6 +288,13 @@ body { display: flex; flex-direction: column; 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: + 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%); + background-attachment: fixed; color: var(--color-text); } @@ -278,8 +314,10 @@ img, svg { /* Navigation */ .site-nav { - background: var(--color-surface); - border-bottom: 1px solid var(--color-border); + background: var(--glass-bg); + -webkit-backdrop-filter: var(--glass-blur); + backdrop-filter: var(--glass-blur); + border-bottom: 1px solid var(--glass-edge); height: var(--nav-height); position: sticky; top: 0; @@ -333,6 +371,7 @@ img, svg { align-items: center; gap: var(--space-lg); list-style: none; + margin-left: var(--space-lg); } .site-nav__links a { @@ -347,6 +386,20 @@ img, svg { text-decoration: none; } +.site-nav__links a.site-nav__link--active { + color: var(--color-text); + font-weight: 600; +} + +.site-nav__profile-link { + color: inherit; + text-decoration: none; +} + +.site-nav__profile-link:hover { + color: var(--color-text); +} + .site-nav__right { display: flex; align-items: center; @@ -359,6 +412,7 @@ img, svg { gap: var(--space-sm); color: var(--color-text-muted); font-size: var(--text-sm); + white-space: nowrap; } /* Main content */ @@ -383,6 +437,51 @@ img, svg { font-weight: 700; } +/* Plan page header: file icon + title, byline underneath — compact, the + document itself is the point. */ +.page-header--plan { + margin-bottom: var(--space-sm); + align-items: flex-start; +} + +.page-header__title-row { + display: flex; + align-items: center; + gap: var(--space-sm); +} + +.page-header__title { + min-width: 0; + overflow-wrap: anywhere; +} + +.page-header__byline { + display: flex; + align-items: center; + gap: var(--space-xs); + flex-wrap: wrap; + margin-top: var(--space-xs); +} + +/* Shelf chips (left) + owner actions (right) share one compact row. */ +.plan-meta-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + flex-wrap: wrap; + margin-bottom: var(--space-md); +} + +.plan-meta-row .plan-shelves, +.plan-meta-row .plan-actions { + margin-bottom: 0; +} + +.plan-meta-row .plan-actions { + margin-left: auto; +} + /* Plan viewers / presence */ .plan-viewers { display: flex; @@ -506,13 +605,15 @@ img, svg { } .btn--primary { - background: var(--color-primary); + background: linear-gradient(180deg, var(--color-primary) 0%, var(--color-primary-hover) 100%); color: var(--color-text-inverse); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 1px 2px rgba(15, 23, 42, 0.2); } .btn--primary:hover { - background: var(--color-primary-hover); + filter: brightness(1.07); color: var(--color-text-inverse); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 2px 6px rgba(15, 23, 42, 0.25); } .btn--secondary { @@ -525,6 +626,18 @@ img, svg { background: var(--color-bg); } +/* Quiet text-style button for low-emphasis actions (e.g. Archive). */ +.btn--ghost { + background: transparent; + color: var(--color-text-muted); + border-color: transparent; +} + +.btn--ghost:hover { + background: var(--color-bg); + color: var(--color-text); +} + .btn--danger { background: var(--color-danger); color: var(--color-text-inverse); @@ -537,11 +650,13 @@ img, svg { /* Cards */ .card { - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius); + background: var(--glass-bg-strong); + -webkit-backdrop-filter: var(--glass-blur); + backdrop-filter: var(--glass-blur); + border: 1px solid var(--glass-edge); + border-radius: var(--radius-lg); padding: var(--space-lg); - box-shadow: var(--shadow); + box-shadow: var(--shadow-glass); } /* Status badges */ @@ -555,15 +670,27 @@ img, svg { letter-spacing: 0.025em; } -/* Status badge that opens the status menu (decorated by status_menu_controller). */ -.badge--menu { cursor: pointer; } -.badge--menu::after { content: " ▾"; opacity: 0.7; } +/* Plan state badges. Published-and-active plans get no badge — these mark + the exceptional states only. (The --color-status-* tokens are shared + with comment-thread badges below.) */ +.badge--archived { background: var(--color-status-abandoned-bg); color: var(--color-status-abandoned); } + +/* Hidden-state flag: a quiet crossed-out eye + word, no background. The + normal (published) state is deliberately unmarked. */ +.state-flag { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.75rem; + font-weight: 500; + color: var(--color-text-muted); + white-space: nowrap; +} -.badge--brainstorm { background: var(--color-status-brainstorm-bg); color: var(--color-status-brainstorm); } -.badge--considering { background: var(--color-status-considering-bg); color: var(--color-status-considering); } -.badge--developing { background: var(--color-status-developing-bg); color: var(--color-status-developing); } -.badge--live { background: var(--color-status-live-bg); color: var(--color-status-live); } -.badge--abandoned { background: var(--color-status-abandoned-bg); color: var(--color-status-abandoned); } +.state-flag__eye { + flex-shrink: 0; + opacity: 0.8; +} .badge--pending { background: var(--color-status-considering-bg); color: var(--color-status-considering); } .badge--todo { background: var(--color-status-developing-bg); color: var(--color-status-developing); } .badge--discarded { background: var(--color-status-abandoned-bg); color: var(--color-status-abandoned); } @@ -578,6 +705,35 @@ img, svg { .badge--tag-active { background: var(--color-tag-active-bg); color: var(--color-text-inverse); } .badge--tag-active:hover { background: var(--color-tag-active-hover-bg); color: var(--color-text-inverse); } +/* Document-type file icon: a colored rounded square with the type's + glyph, like a Drive/Finder file icon. It represents the file — it + leads the title and never reads as a tag; the type's name lives in the + tooltip. Tint classes are picked per type name (PLAN_TYPE_COLOR_COUNT + in PlansHelper must match the number of tints here). */ +.plan-type-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + border-radius: 6px; + background: var(--color-type-bg); + color: var(--color-type-text); +} + +.plan-type-icon--sm { width: 22px; height: 22px; border-radius: 5px; } +.plan-type-icon--sm svg { width: 12px; height: 12px; } +.plan-type-icon--md { width: 26px; height: 26px; } +.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). */ +.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; } +.plan-type-icon--3 { background: color-mix(in srgb, #9c6fe0 16%, transparent); color: #9c6fe0; } +.plan-type-icon--4 { background: color-mix(in srgb, #e0637a 16%, transparent); color: #e0637a; } +.plan-type-icon--5 { background: color-mix(in srgb, #2fa8a0 16%, transparent); color: #2fa8a0; } + /* Avatars */ .avatar { display: inline-flex; @@ -590,6 +746,8 @@ img, svg { .avatar--sm { width: 1.5rem; height: 1.5rem; font-size: 0.6rem; } .avatar--md { width: 2rem; height: 2rem; font-size: 0.7rem; } +.avatar--lg { width: 3rem; height: 3rem; font-size: 1rem; } +.avatar--xl { width: 4rem; height: 4rem; font-size: 1.3rem; } .avatar--initials { background: var(--color-primary); @@ -776,6 +934,76 @@ img.avatar { margin-top: var(--space-md); } +/* Chip-style tag editor: an input-shaped shell holding tag pills plus a + borderless inline text field. */ +.tag-input { + padding: 6px; + border: 1px solid var(--color-border-strong); + border-radius: var(--radius); + background: var(--color-input-bg); + cursor: text; +} + +.tag-input:focus-within { + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-focus-ring); +} + +.tag-input__chips { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; +} + +.tag-input__chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 6px 2px 10px; + border-radius: 9999px; + background: var(--color-tag-bg); + color: var(--color-tag-text); + font-size: var(--text-sm); + line-height: 1.5; +} + +.tag-input__remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + border: none; + border-radius: 9999px; + background: transparent; + color: inherit; + font-size: 0.9rem; + line-height: 1; + cursor: pointer; +} + +.tag-input__remove:hover { + background: var(--color-tag-hover-bg); + color: var(--color-danger); +} + +.tag-input__field { + flex: 1; + min-width: 140px; + padding: 4px; + border: none; + background: transparent; + color: var(--color-text); + font-family: inherit; + font-size: var(--text-sm); +} + +.tag-input__field:focus { + outline: none; +} + /* Plan content */ .plan-content pre { font-family: var(--font-mono); @@ -949,13 +1177,22 @@ img.avatar { .mt-md { margin-top: var(--space-md); } .mb-md { margin-bottom: var(--space-md); } -/* Plan view toggle */ -.plan-view-toggle { +/* "Changed since you last looked" — one-time, per-viewer section + highlights (changed_sections_controller). Quiet left accent + faint + tint; gone on the next visit. */ +.section-changed { + box-shadow: inset 3px 0 0 var(--color-primary); + background: color-mix(in srgb, var(--color-primary) 5%, transparent); + border-radius: 0 var(--radius) var(--radius) 0; + padding-left: var(--space-sm); + margin-left: calc(-1 * var(--space-sm)); +} + +.changed-sections-note { display: flex; + align-items: center; gap: var(--space-xs); margin-bottom: var(--space-md); - padding-bottom: var(--space-md); - border-bottom: 1px solid var(--color-border); } /* Rendered markdown */ @@ -1389,6 +1626,24 @@ img.avatar { box-shadow: 0 0 0 3px var(--color-focus-ring); } +/* On phones the composer is a fixed, full-width bottom sheet — an + absolutely-positioned box runs off the viewport, and the software + keyboard fights it (text_selection_controller adds this class). */ +.comment-form--sheet { + position: fixed; + top: auto !important; + left: 0 !important; + right: 0; + bottom: 0; + width: 100%; + max-height: 55vh; + overflow-y: auto; + margin: 0; + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + border-bottom: none; + z-index: 300; +} + /* Comment thread */ .comment-thread { padding: var(--space-md); @@ -1548,43 +1803,6 @@ img.avatar { min-width: 200px; } -/* Dropdown */ -.dropdown { - position: relative; - display: inline-block; -} - -.dropdown__menu { - position: absolute; - right: 0; - top: 100%; - margin-top: var(--space-xs); - padding: var(--space-xs) 0; - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius); - box-shadow: var(--shadow-lg); - min-width: 220px; - z-index: 100; -} - -.dropdown__item { - display: block; - width: 100%; - padding: 0.625rem var(--space-md); - line-height: 1.45; - text-align: left; - background: none; - border: none; - cursor: pointer; - font-size: var(--text-sm); - color: var(--color-text); -} - -.dropdown__item:hover { - background: var(--color-bg-muted); -} - .settings-section-subtitle { margin-bottom: var(--space-md); color: var(--color-text-muted); @@ -1733,12 +1951,6 @@ img.avatar { font-weight: 600; } -/* Page header */ -.page-header__subtitle { - color: var(--color-text-muted); - margin-top: var(--space-xs); -} - /* Thread popover data containers (hidden, provide data for JS) */ .thread-popover-data { display: contents; @@ -1763,6 +1975,20 @@ img.avatar { background: transparent; } +/* Thread popovers become a bottom sheet on phones (same treatment as the + composer — set by the positioning code, not a media query, because the + inline top/left must also be cleared). */ +.thread-popover--sheet { + top: auto !important; + left: 0 !important; + right: 0; + bottom: 0; + width: 100%; + max-height: 60vh; + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + border-bottom: none; +} + .thread-popover__header { display: flex; gap: var(--space-xs); @@ -1843,60 +2069,6 @@ img.avatar { display: flex; } -/* Ensure content doesn't get hidden behind fixed toolbar */ -body:has(.comment-toolbar) .main-content { - padding-bottom: 3.5rem; -} - -/* Comment toolbar (bottom-pinned) */ -.comment-toolbar { - position: fixed; - bottom: 0; - left: 0; - right: 0; - background: var(--color-surface); - border-top: 1px solid var(--color-border); - padding: var(--space-sm) var(--space-lg); - display: flex; - align-items: center; - gap: var(--space-lg); - z-index: 50; - justify-content: center; - box-shadow: var(--shadow-top); -} - -.comment-toolbar__count { - font-size: var(--text-sm); - font-weight: 600; - color: var(--color-text); -} - -.comment-toolbar__nav { - display: flex; - align-items: center; - gap: var(--space-xs); -} - -.comment-toolbar__position { - font-size: var(--text-sm); - color: var(--color-text-muted); - min-width: 4rem; - text-align: center; -} - -.comment-toolbar__toggle { - font-size: var(--text-sm); - color: var(--color-text-muted); - cursor: pointer; - display: flex; - align-items: center; - gap: var(--space-xs); -} - -.comment-toolbar__toggle input[type="checkbox"] { - cursor: pointer; -} - /* Content navigation sidebar */ .content-nav { position: sticky; @@ -2157,9 +2329,11 @@ body:has(.comment-toolbar) .main-content { right: 0; width: 420px; max-height: 480px; - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius); + background: var(--glass-bg-strong); + -webkit-backdrop-filter: var(--glass-blur); + backdrop-filter: var(--glass-blur); + border: 1px solid var(--glass-edge); + border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); z-index: 200; overflow: hidden; @@ -2345,51 +2519,207 @@ body:has(.comment-toolbar) .main-content { margin-top: var(--space-sm); } -/* Plan tabs */ -.plan-tabs__nav { +/* Plan document + footnotes: one page, no tabs. The document is the main + glass card; references and attachments follow like back matter — same + page, quieter surface. */ +.plan-footnotes { + margin-top: var(--space-xl); display: flex; - gap: var(--space-xs); - margin-bottom: calc(-1px); - position: relative; - z-index: 1; + flex-direction: column; + gap: var(--space-lg); } -.plan-tabs__tab { - padding: var(--space-sm) var(--space-lg); - font-size: var(--text-sm); - font-weight: 500; - color: var(--color-text-muted); - text-decoration: none; - border: 1px solid transparent; - border-bottom: none; - border-radius: var(--radius) var(--radius) 0 0; - transition: color 0.15s, background 0.15s; - cursor: pointer; +.plan-footnote { + /* Set apart from the document proper: flatter, edge-lit, no heavy card. */ + background: var(--surface-translucent); + border: 1px solid var(--glass-edge); + border-left: 3px solid var(--color-border); + border-radius: var(--radius); + scroll-margin-top: var(--space-lg); } -.plan-tabs__tab:hover { - color: var(--color-text); - background: var(--color-bg); - text-decoration: none; +.plan-footnote__header { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-md) var(--space-lg) 0; } -.plan-tabs__tab--active { - color: var(--color-text); - background: var(--color-surface); - border-color: var(--color-border); +.plan-footnote__title { + margin: 0; + font-size: var(--text-sm); font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-muted); } -.plan-tabs__count { +.section-count { font-size: 0.75rem; color: var(--color-text-muted); font-weight: 400; } -.plan-tabs__panel--hidden { +/* The round "+" leading a footnote section — one click, opens the add + modal. Icon only; the section title is right beside it. */ +.section-add { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + background: var(--color-bg-muted); + border: 1px solid var(--color-border); + border-radius: 50%; + 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); +} + +/* Back-matter links at the bottom of the document outline. */ +.content-nav__list--footnotes { + margin-top: var(--space-sm); + padding-top: var(--space-sm); + border-top: 1px solid var(--color-border); +} + +.content-nav__footnote-link { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-xs); + padding: 2px var(--space-sm); + font-size: var(--text-xs); + color: var(--color-text-muted); + text-decoration: none; + border-radius: var(--radius); +} + +.content-nav__footnote-link:hover { + color: var(--color-text); + background: var(--color-bg-muted); + text-decoration: none; +} + +/* The visibility eye: slash appears when Private (matching the state + flag's crossed eye); armed = mid-flip, waiting for the confirming + second click. */ +.visibility-toggle .visibility-toggle__slash { display: none; } +.visibility-toggle--hidden .visibility-toggle__slash { + display: initial; +} + +.visibility-toggle--armed { + color: var(--color-primary); + animation: visibility-pulse 1.2s ease-in-out infinite; +} + +@keyframes visibility-pulse { + 50% { opacity: 0.45; } +} + +/* Folder-picker navigator (the bookmark's popover) */ +.folder-picker__plan { + margin: 0 0 var(--space-sm); +} + +.folder-picker__tree { + list-style: none; + margin: 0; + padding: 0; + max-height: 20rem; + overflow-y: auto; +} + +.folder-picker__tree--nested { + padding-left: var(--space-md); + max-height: none; + overflow: visible; +} + +.folder-picker__option { + display: flex; + align-items: center; + gap: var(--space-xs); + width: 100%; + padding: 5px var(--space-sm); + background: none; + border: none; + border-radius: var(--radius); + color: var(--color-text); + font-size: var(--text-sm); + font-family: inherit; + text-align: left; + cursor: pointer; +} + +.folder-picker__option:hover { + background: var(--color-bg-muted); + color: var(--color-primary); +} + +.folder-picker__icon { + flex-shrink: 0; + color: var(--color-text-muted); +} + +.folder-picker__name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.folder-picker__check { + visibility: hidden; + color: var(--color-primary); +} + +.folder-picker__option--current .folder-picker__check { + visibility: visible; +} + +.folder-picker__option--current { + font-weight: 600; +} + +/* History page (the plan's time machine) */ +.history-page { + padding: var(--space-lg); +} + +.history-back { + display: inline-flex; + align-items: center; + padding: 4px; + border-radius: var(--radius); + color: var(--color-text-muted); +} + +.history-back:hover { + color: var(--color-primary); + background: var(--color-bg-muted); +} + +.history-title-link { + color: inherit; + text-decoration: none; +} + +.history-title-link:hover { + color: var(--color-primary); +} + /* References section */ .references-section { padding: var(--space-lg); @@ -2483,30 +2813,91 @@ body:has(.comment-toolbar) .main-content { margin-bottom: var(--space-md); } -.references-add summary { - cursor: pointer; - list-style: none; +/* Reusable "add" lightbox (references, attachments). Same glass treatment + as the search modal; the Popover API supplies backdrop and dismissal. */ +.add-modal { + position: fixed; + top: 14vh; + left: 50%; + transform: translateX(-50%); + width: min(440px, 92vw); + max-height: 72vh; + padding: 0; + margin: 0; + border: 1px solid var(--glass-edge); + border-radius: var(--radius-lg); + background: var(--glass-bg-strong); + -webkit-backdrop-filter: var(--glass-blur); + backdrop-filter: var(--glass-blur); + color: var(--color-text); + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.25); + overflow: hidden; } -.references-add summary::-webkit-details-marker { +.add-modal:not(:popover-open) { display: none; } -.references-add__form { - margin-top: var(--space-sm); +.add-modal:popover-open { display: flex; - align-items: flex-end; - gap: var(--space-xs); - flex-wrap: wrap; + flex-direction: column; } -.references-add__form .form-group { - margin-bottom: 0; - flex: 1; - min-width: 140px; +.add-modal::backdrop { + background: rgba(0, 0, 0, 0.35); +} + +.add-modal__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-sm); + padding: var(--space-md) var(--space-lg); + border-bottom: 1px solid var(--color-border); +} + +.add-modal__title { + margin: 0; + font-size: var(--text-md); + font-weight: 600; +} + +.add-modal__close { + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + border: none; + border-radius: var(--radius); + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + transition: color 0.15s, background 0.15s; } -.references-add__form input[type="text"] { +.add-modal__close:hover { + color: var(--color-text); + background: var(--color-bg); +} + +.add-modal__body { + padding: var(--space-lg); + overflow-y: auto; +} + +.add-modal__form .form-group { + margin-bottom: var(--space-md); +} + +.add-modal__form label { + display: block; + margin-bottom: 4px; + font-size: var(--text-sm); + font-weight: 500; + color: var(--color-text-muted); +} + +.add-modal__form input[type="text"] { width: 100%; padding: 6px var(--space-sm); font-size: var(--text-sm); @@ -2518,18 +2909,20 @@ body:has(.comment-toolbar) .main-content { transition: border-color 0.15s; } -.references-add__form input[type="text"]:focus { +.add-modal__form input[type="text"]:focus { outline: none; border-color: var(--color-primary); box-shadow: 0 0 0 3px var(--color-focus-ring); } -.references-add__form input[type="text"]::placeholder { +.add-modal__form input[type="text"]::placeholder { color: var(--color-text-muted); } -.references-add__form .btn { - flex-shrink: 0; +.add-modal__actions { + display: flex; + justify-content: flex-end; + margin-top: var(--space-lg); } /* Attachments section */ @@ -2541,57 +2934,61 @@ body:has(.comment-toolbar) .main-content { margin-bottom: var(--space-md); } -/* Drag-and-drop upload zone (see dropzone_controller.js). The whole inner - area is a