Compare changes

Choose any two refs to compare.

+3167 -367
+1 -1
crates/weaver-app/Cargo.toml
··· 57 jacquard-axum = { workspace = true, optional = true } 58 weaver-api = { path = "../weaver-api", features = ["com_whtwnd"] } 59 markdown-weaver = { workspace = true } 60 - weaver-renderer = { path = "../weaver-renderer", features = ["themes"] } 61 n0-future = { workspace = true } 62 dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false, features = ["router"] } 63 axum = { version = "0.8.6", optional = true }
··· 57 jacquard-axum = { workspace = true, optional = true } 58 weaver-api = { path = "../weaver-api", features = ["com_whtwnd"] } 59 markdown-weaver = { workspace = true } 60 + weaver-renderer = { path = "../weaver-renderer", features = ["themes", "syntax-highlighting", "syntax-css"] } 61 n0-future = { workspace = true } 62 dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false, features = ["router"] } 63 axum = { version = "0.8.6", optional = true }
+107 -1
crates/weaver-app/assets/styling/inline-theme-editor.css
··· 1 /* Inline theme editor for notebook settings */ 2 3 .inline-theme-editor { 4 display: flex; 5 flex-direction: column; 6 gap: 1rem; ··· 43 border-color: var(--color-primary); 44 } 45 46 .inline-theme-editor-colours { 47 display: grid; 48 - grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); 49 gap: 0.75rem; 50 } 51 52 .inline-theme-editor-code-theme, 53 .inline-theme-editor-mode { 54 display: flex; ··· 81 gap: 0.5rem; 82 } 83 84 .inline-theme-editor-advanced-content { 85 display: flex; 86 flex-direction: column; ··· 91 border-radius: 0; 92 } 93 94 .inline-theme-editor-variant { 95 display: flex; 96 flex-direction: column; ··· 113 .inline-theme-editor-full-link:hover { 114 text-decoration: underline; 115 }
··· 1 /* Inline theme editor for notebook settings */ 2 3 .inline-theme-editor { 4 + container-type: inline-size; 5 display: flex; 6 flex-direction: column; 7 gap: 1rem; ··· 44 border-color: var(--color-primary); 45 } 46 47 + /* Main theme controls section - two columns when wide */ 48 + .inline-theme-editor-main { 49 + display: flex; 50 + flex-direction: column; 51 + gap: 1rem; 52 + } 53 + 54 + @container (min-width: 32rem) { 55 + .inline-theme-editor-main { 56 + display: grid; 57 + grid-template-columns: 1fr 1fr; 58 + gap: 1rem 1.5rem; 59 + } 60 + } 61 + 62 + /* Left: colours and mode toggle */ 63 + .inline-theme-editor-main-left { 64 + display: flex; 65 + flex-direction: column; 66 + gap: 0.75rem; 67 + } 68 + 69 + /* Right: dropdowns */ 70 + .inline-theme-editor-main-right { 71 + display: flex; 72 + flex-direction: column; 73 + gap: 0.75rem; 74 + } 75 + 76 .inline-theme-editor-colours { 77 display: grid; 78 + grid-template-columns: 1fr; 79 gap: 0.75rem; 80 } 81 82 + @container (min-width: 20rem) { 83 + .inline-theme-editor-colours { 84 + grid-template-columns: repeat(2, 1fr); 85 + } 86 + } 87 + 88 .inline-theme-editor-code-theme, 89 .inline-theme-editor-mode { 90 display: flex; ··· 117 gap: 0.5rem; 118 } 119 120 + /* Advanced colour options - hidden by default in narrow containers */ 121 + .inline-theme-editor-advanced { 122 + display: none; 123 + } 124 + 125 + /* Show advanced when container is wide enough */ 126 + @container (min-width: 40rem) { 127 + .inline-theme-editor-advanced { 128 + display: block; 129 + } 130 + } 131 + 132 + /* Force show advanced via class (for prop override) */ 133 + .inline-theme-editor-advanced.force-show { 134 + display: block; 135 + } 136 + 137 + /* Force hide advanced via class (for prop override) */ 138 + .inline-theme-editor-advanced.force-hide { 139 + display: none; 140 + } 141 + 142 .inline-theme-editor-advanced-content { 143 display: flex; 144 flex-direction: column; ··· 149 border-radius: 0; 150 } 151 152 + /* Side-by-side variants when wide */ 153 + @container (min-width: 40rem) { 154 + .inline-theme-editor-advanced-content { 155 + display: grid; 156 + grid-template-columns: 1fr 1fr; 157 + gap: 1.5rem; 158 + } 159 + } 160 + 161 .inline-theme-editor-variant { 162 display: flex; 163 flex-direction: column; ··· 180 .inline-theme-editor-full-link:hover { 181 text-decoration: underline; 182 } 183 + 184 + /* Preview section */ 185 + .inline-theme-editor-preview { 186 + display: flex; 187 + flex-direction: column; 188 + gap: 0.5rem; 189 + margin-top: 0.5rem; 190 + border-top: 1px solid var(--color-border); 191 + padding-top: 1rem; 192 + } 193 + 194 + .inline-theme-editor-preview-header { 195 + display: flex; 196 + align-items: center; 197 + justify-content: space-between; 198 + gap: 1rem; 199 + } 200 + 201 + .inline-theme-editor-preview-header h5 { 202 + margin: 0; 203 + font-size: 0.875rem; 204 + font-weight: 600; 205 + color: var(--color-muted); 206 + } 207 + 208 + .inline-theme-editor-preview .theme-preview { 209 + border: 1px solid var(--color-border); 210 + border-radius: 0; 211 + max-height: 20rem; 212 + overflow-y: auto; 213 + padding: 1rem; 214 + } 215 + 216 + .inline-theme-editor-preview .theme-preview--loading, 217 + .inline-theme-editor-preview .theme-preview--error { 218 + padding: 1rem; 219 + font-size: 0.875rem; 220 + color: var(--color-muted); 221 + }
+82 -3
crates/weaver-app/assets/styling/notebook-editor.css
··· 1 /* Notebook editor form component */ 2 3 .notebook-editor { 4 display: flex; 5 flex-direction: column; 6 gap: 1rem; ··· 18 gap: 1rem; 19 } 20 21 .notebook-editor-field { 22 display: flex; 23 flex-direction: column; ··· 49 } 50 51 .notebook-editor-toggle { 52 - flex-direction: row; 53 - align-items: flex-start; 54 - gap: 0.5rem; 55 } 56 57 .notebook-editor-toggle label { ··· 150 justify-content: flex-end; 151 padding-top: 0.5rem; 152 }
··· 1 /* Notebook editor form component */ 2 3 .notebook-editor { 4 + container-type: inline-size; 5 display: flex; 6 flex-direction: column; 7 gap: 1rem; ··· 19 gap: 1rem; 20 } 21 22 + /* Top fields section - two columns when wide */ 23 + .notebook-editor-top { 24 + display: flex; 25 + flex-direction: column; 26 + gap: 1rem; 27 + } 28 + 29 + @container (min-width: 40rem) { 30 + .notebook-editor-top { 31 + display: grid; 32 + grid-template-columns: 1fr 1fr; 33 + gap: 1rem 1.5rem; 34 + } 35 + } 36 + 37 + /* Left column: title, path */ 38 + .notebook-editor-top-left { 39 + display: flex; 40 + flex-direction: column; 41 + gap: 1rem; 42 + } 43 + 44 + /* Right column: publish globally, tags */ 45 + .notebook-editor-top-right { 46 + display: flex; 47 + flex-direction: column; 48 + gap: 1rem; 49 + } 50 + 51 .notebook-editor-field { 52 display: flex; 53 flex-direction: column; ··· 79 } 80 81 .notebook-editor-toggle { 82 + /* Keep column direction from .notebook-editor-field, just adjust gap */ 83 + gap: 0.25rem; 84 } 85 86 .notebook-editor-toggle label { ··· 179 justify-content: flex-end; 180 padding-top: 0.5rem; 181 } 182 + 183 + /* Content settings section */ 184 + .notebook-editor-content-settings { 185 + display: flex; 186 + flex-direction: column; 187 + gap: 1rem; 188 + padding-top: 1rem; 189 + border-top: 1px solid var(--color-border); 190 + } 191 + 192 + .notebook-editor-content-settings h4 { 193 + margin: 0; 194 + font-size: 0.875rem; 195 + font-weight: 600; 196 + color: var(--color-muted); 197 + text-transform: uppercase; 198 + letter-spacing: 0.05em; 199 + } 200 + 201 + .notebook-editor-warning { 202 + background: color-mix(in srgb, var(--color-warning) 15%, var(--color-surface)); 203 + border: 1px solid color-mix(in srgb, var(--color-warning) 40%, transparent); 204 + } 205 + 206 + .notebook-editor-field select { 207 + padding: 0.5rem; 208 + padding-right: 2rem; 209 + border: 1px solid var(--color-border); 210 + border-radius: 0; 211 + background: var(--color-base); 212 + color: var(--color-text); 213 + font-size: 0.875rem; 214 + font-family: inherit; 215 + appearance: none; 216 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%239ca3af' d='M6 8L2 4h8z'/%3E%3C/svg%3E"); 217 + background-repeat: no-repeat; 218 + background-position: right 0.5rem center; 219 + cursor: pointer; 220 + } 221 + 222 + .notebook-editor-field select:focus { 223 + outline: 2px solid var(--color-primary); 224 + outline-offset: -1px; 225 + } 226 + 227 + .notebook-editor-field select option { 228 + background: var(--color-surface); 229 + color: var(--color-text); 230 + padding: 0.5rem; 231 + }
+321
crates/weaver-app/assets/styling/notebook-settings.css
···
··· 1 + /* Notebook settings page */ 2 + 3 + .notebook-settings { 4 + display: grid; 5 + grid-template-columns: 200px 1fr; 6 + gap: 2rem; 7 + max-width: 100rem; 8 + margin: 0 auto; 9 + padding: 1.5rem; 10 + min-height: calc(100vh - 4rem); 11 + } 12 + 13 + @media (max-width: 768px) { 14 + .notebook-settings { 15 + grid-template-columns: 1fr; 16 + gap: 1rem; 17 + } 18 + } 19 + 20 + /* Sidebar navigation */ 21 + .notebook-settings-nav { 22 + display: flex; 23 + flex-direction: column; 24 + gap: 0.25rem; 25 + position: sticky; 26 + top: 1rem; 27 + align-self: start; 28 + } 29 + 30 + @media (max-width: 768px) { 31 + .notebook-settings-nav { 32 + flex-direction: row; 33 + flex-wrap: wrap; 34 + position: static; 35 + border-bottom: 1px solid var(--color-border); 36 + padding-bottom: 1rem; 37 + } 38 + } 39 + 40 + .notebook-settings-nav button { 41 + padding: 0.75rem 1rem; 42 + text-align: left; 43 + background: transparent; 44 + border: none; 45 + color: var(--color-muted); 46 + font-size: 0.875rem; 47 + cursor: pointer; 48 + transition: color 0.15s, background 0.15s; 49 + } 50 + 51 + .notebook-settings-nav button:hover { 52 + color: var(--color-text); 53 + background: var(--color-surface); 54 + } 55 + 56 + .notebook-settings-nav button.active { 57 + color: var(--color-text); 58 + background: var(--color-surface); 59 + font-weight: 600; 60 + } 61 + 62 + /* Content area */ 63 + .notebook-settings-content { 64 + min-width: 0; 65 + } 66 + 67 + /* Section styling */ 68 + .notebook-settings-section { 69 + display: flex; 70 + flex-direction: column; 71 + gap: 1.5rem; 72 + } 73 + 74 + .notebook-settings-section h2 { 75 + margin: 0; 76 + font-size: 1.5rem; 77 + font-weight: 600; 78 + color: var(--color-text); 79 + padding-bottom: 0.75rem; 80 + border-bottom: 1px solid var(--color-border); 81 + } 82 + 83 + .notebook-settings-description { 84 + margin: 0; 85 + color: var(--color-muted); 86 + font-size: 0.875rem; 87 + } 88 + 89 + /* Form fields */ 90 + .notebook-settings-field { 91 + display: flex; 92 + flex-direction: column; 93 + gap: 0.5rem; 94 + } 95 + 96 + .notebook-settings-field label { 97 + font-size: 0.875rem; 98 + font-weight: 500; 99 + color: var(--color-text); 100 + } 101 + 102 + .notebook-settings-field input[type="text"], 103 + .notebook-settings-field select { 104 + padding: 0.625rem 0.75rem; 105 + border: 1px solid var(--color-border); 106 + background: var(--color-base); 107 + color: var(--color-text); 108 + font-family: var(--font-ui); 109 + font-size: 0.875rem; 110 + } 111 + 112 + .notebook-settings-field input:focus, 113 + .notebook-settings-field select:focus { 114 + outline: none; 115 + border-color: var(--color-primary); 116 + } 117 + 118 + .notebook-settings-hint { 119 + font-size: 0.75rem; 120 + color: var(--color-muted); 121 + } 122 + 123 + /* Toggle field */ 124 + .notebook-settings-toggle label { 125 + display: flex; 126 + align-items: center; 127 + gap: 0.5rem; 128 + cursor: pointer; 129 + } 130 + 131 + .notebook-settings-toggle input[type="checkbox"] { 132 + width: 1rem; 133 + height: 1rem; 134 + } 135 + 136 + /* Tags */ 137 + .notebook-settings-tags { 138 + display: flex; 139 + flex-wrap: wrap; 140 + gap: 0.5rem; 141 + padding: 0.5rem; 142 + border: 1px solid var(--color-border); 143 + background: var(--color-base); 144 + min-height: 2.5rem; 145 + } 146 + 147 + .notebook-settings-tag { 148 + display: inline-flex; 149 + align-items: center; 150 + gap: 0.25rem; 151 + padding: 0.25rem 0.5rem; 152 + background: var(--color-surface); 153 + border: 1px solid var(--color-border); 154 + font-size: 0.75rem; 155 + color: var(--color-text); 156 + } 157 + 158 + .notebook-settings-tag-remove { 159 + background: none; 160 + border: none; 161 + padding: 0; 162 + margin-left: 0.25rem; 163 + color: var(--color-muted); 164 + cursor: pointer; 165 + font-size: 1rem; 166 + line-height: 1; 167 + } 168 + 169 + .notebook-settings-tag-remove:hover { 170 + color: var(--color-error); 171 + } 172 + 173 + .notebook-settings-tags-input { 174 + flex: 1; 175 + min-width: 100px; 176 + border: none !important; 177 + background: transparent !important; 178 + padding: 0.25rem !important; 179 + } 180 + 181 + .notebook-settings-tags-input:focus { 182 + outline: none; 183 + } 184 + 185 + /* Actions */ 186 + .notebook-settings-actions { 187 + display: flex; 188 + justify-content: flex-start; 189 + gap: 0.75rem; 190 + padding-top: 1rem; 191 + border-top: 1px solid var(--color-border); 192 + } 193 + 194 + /* Error display */ 195 + .notebook-settings-error { 196 + padding: 0.75rem 1rem; 197 + background: color-mix(in srgb, var(--color-error) 10%, transparent); 198 + border: 1px solid var(--color-error); 199 + color: var(--color-error); 200 + font-size: 0.875rem; 201 + } 202 + 203 + /* Theme section - needs full width */ 204 + .notebook-settings-theme { 205 + container-type: inline-size; 206 + } 207 + 208 + .notebook-settings-theme .theme-editor-page { 209 + padding: 0; 210 + max-width: none; 211 + } 212 + 213 + /* On smaller screens, stack the theme editor */ 214 + @container (max-width: 50rem) { 215 + .notebook-settings-theme .theme-editor-page { 216 + grid-template-columns: 1fr; 217 + } 218 + 219 + .notebook-settings-theme .theme-editor-preview { 220 + position: static; 221 + max-height: none; 222 + } 223 + } 224 + 225 + /* Danger zone */ 226 + .notebook-settings-danger h2 { 227 + color: var(--color-error); 228 + border-color: var(--color-error); 229 + } 230 + 231 + .notebook-settings-danger-item { 232 + display: flex; 233 + align-items: center; 234 + justify-content: space-between; 235 + gap: 1.5rem; 236 + padding: 1rem; 237 + background: color-mix(in srgb, var(--color-error) 5%, transparent); 238 + border: 1px solid color-mix(in srgb, var(--color-error) 30%, transparent); 239 + } 240 + 241 + .notebook-settings-danger-info h3 { 242 + margin: 0 0 0.25rem; 243 + font-size: 1rem; 244 + font-weight: 600; 245 + color: var(--color-text); 246 + } 247 + 248 + .notebook-settings-danger-info p { 249 + margin: 0; 250 + font-size: 0.875rem; 251 + color: var(--color-muted); 252 + } 253 + 254 + /* Confirm dialog */ 255 + .notebook-settings-confirm-overlay { 256 + position: fixed; 257 + inset: 0; 258 + background: rgba(0, 0, 0, 0.5); 259 + display: flex; 260 + align-items: center; 261 + justify-content: center; 262 + z-index: 1000; 263 + } 264 + 265 + .notebook-settings-confirm-dialog { 266 + background: var(--color-surface); 267 + border: 1px solid var(--color-border); 268 + padding: 1.5rem; 269 + max-width: 400px; 270 + width: 90%; 271 + } 272 + 273 + .notebook-settings-confirm-dialog h3 { 274 + margin: 0 0 0.5rem; 275 + font-size: 1.125rem; 276 + font-weight: 600; 277 + color: var(--color-text); 278 + } 279 + 280 + .notebook-settings-confirm-dialog p { 281 + margin: 0 0 1.5rem; 282 + color: var(--color-muted); 283 + font-size: 0.875rem; 284 + } 285 + 286 + .notebook-settings-confirm-actions { 287 + display: flex; 288 + justify-content: flex-end; 289 + gap: 0.75rem; 290 + } 291 + 292 + /* Unauthorized state */ 293 + .notebook-settings-unauthorized { 294 + display: flex; 295 + flex-direction: column; 296 + align-items: center; 297 + justify-content: center; 298 + min-height: 50vh; 299 + text-align: center; 300 + padding: 2rem; 301 + } 302 + 303 + .notebook-settings-unauthorized h1 { 304 + margin: 0 0 0.5rem; 305 + font-size: 1.5rem; 306 + color: var(--color-text); 307 + } 308 + 309 + .notebook-settings-unauthorized p { 310 + margin: 0; 311 + color: var(--color-muted); 312 + } 313 + 314 + /* Placeholder */ 315 + .notebook-settings-placeholder { 316 + padding: 2rem; 317 + text-align: center; 318 + color: var(--color-muted); 319 + background: var(--color-surface); 320 + border: 1px dashed var(--color-border); 321 + }
+336
crates/weaver-app/assets/styling/theme-editor.css
···
··· 1 + /* Full theme editor page */ 2 + 3 + .theme-editor-page { 4 + display: grid; 5 + grid-template-columns: 1fr; 6 + gap: 2rem; 7 + padding: 1.5rem; 8 + max-width: 100rem; 9 + } 10 + 11 + @media (min-width: 60rem) { 12 + .theme-editor-page { 13 + grid-template-columns: 1fr 400px; 14 + } 15 + } 16 + 17 + /* Left column: controls */ 18 + .theme-editor-controls { 19 + display: flex; 20 + flex-direction: column; 21 + gap: 1.5rem; 22 + } 23 + 24 + /* Right column: preview */ 25 + .theme-editor-preview { 26 + position: sticky; 27 + top: 1rem; 28 + align-self: start; 29 + max-height: calc(100vh - 2rem); 30 + overflow-y: auto; 31 + } 32 + 33 + /* Section styling */ 34 + .theme-editor-section { 35 + display: flex; 36 + flex-direction: column; 37 + gap: 0.75rem; 38 + padding: 1rem; 39 + background: var(--color-surface); 40 + border: 1px solid var(--color-border); 41 + } 42 + 43 + .theme-editor-section-header { 44 + display: flex; 45 + align-items: center; 46 + justify-content: space-between; 47 + gap: 1rem; 48 + } 49 + 50 + .theme-editor-scheme-preset { 51 + padding: 0.375rem 0.5rem; 52 + padding-right: 1.75rem; 53 + border: 1px solid var(--color-border); 54 + background: var(--color-base); 55 + color: var(--color-text); 56 + font-family: var(--font-ui); 57 + font-size: 0.75rem; 58 + appearance: none; 59 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 12 12'%3E%3Cpath fill='%239ca3af' d='M6 8L2 4h8z'/%3E%3C/svg%3E"); 60 + background-repeat: no-repeat; 61 + background-position: right 0.375rem center; 62 + cursor: pointer; 63 + } 64 + 65 + .theme-editor-scheme-preset:focus { 66 + outline: none; 67 + border-color: var(--color-primary); 68 + } 69 + 70 + .theme-editor-scheme-preset option { 71 + background: var(--color-surface); 72 + color: var(--color-text); 73 + } 74 + 75 + .theme-editor-section h3 { 76 + margin: 0; 77 + font-size: 1rem; 78 + font-weight: 600; 79 + color: var(--color-text); 80 + } 81 + 82 + /* Colour grid - 4 columns */ 83 + .theme-editor-colours { 84 + display: grid; 85 + grid-template-columns: repeat(2, 1fr); 86 + gap: 0.75rem; 87 + } 88 + 89 + @media (min-width: 30rem) { 90 + .theme-editor-colours { 91 + grid-template-columns: repeat(4, 1fr); 92 + } 93 + } 94 + 95 + /* Colour groups */ 96 + .theme-editor-colour-group { 97 + display: flex; 98 + flex-direction: column; 99 + gap: 0.5rem; 100 + } 101 + 102 + .theme-editor-colour-group-label { 103 + font-size: 0.75rem; 104 + font-weight: 600; 105 + color: var(--color-muted); 106 + text-transform: uppercase; 107 + letter-spacing: 0.05em; 108 + } 109 + 110 + .theme-editor-colour-group-items { 111 + display: grid; 112 + grid-template-columns: repeat(2, 1fr); 113 + gap: 0.5rem; 114 + } 115 + 116 + @container (min-width: 30rem) { 117 + .theme-editor-colour-group-items { 118 + grid-template-columns: repeat(4, 1fr); 119 + } 120 + } 121 + 122 + /* Fonts section */ 123 + .theme-editor-fonts { 124 + display: grid; 125 + grid-template-columns: 1fr; 126 + gap: 0.75rem; 127 + } 128 + 129 + @container (min-width: 30rem) { 130 + .theme-editor-fonts { 131 + grid-template-columns: repeat(3, 1fr); 132 + } 133 + } 134 + 135 + .theme-editor-font-field { 136 + display: flex; 137 + flex-direction: column; 138 + gap: 0.25rem; 139 + } 140 + 141 + .theme-editor-font-field label { 142 + font-size: 0.875rem; 143 + color: var(--color-muted); 144 + } 145 + 146 + .theme-editor-font-field input, 147 + .theme-editor-font-field select { 148 + padding: 0.5rem; 149 + border: 1px solid var(--color-border); 150 + background: var(--color-base); 151 + color: var(--color-text); 152 + font-family: var(--font-ui); 153 + font-size: 0.875rem; 154 + } 155 + 156 + .theme-editor-font-field input:focus, 157 + .theme-editor-font-field select:focus { 158 + outline: none; 159 + border-color: var(--color-primary); 160 + } 161 + 162 + /* Spacing section */ 163 + .theme-editor-spacing { 164 + display: grid; 165 + grid-template-columns: repeat(3, 1fr); 166 + gap: 0.75rem; 167 + } 168 + 169 + .theme-editor-spacing-field { 170 + display: flex; 171 + flex-direction: column; 172 + gap: 0.25rem; 173 + } 174 + 175 + .theme-editor-spacing-field label { 176 + font-size: 0.875rem; 177 + color: var(--color-muted); 178 + } 179 + 180 + .theme-editor-spacing-field input { 181 + padding: 0.5rem; 182 + border: 1px solid var(--color-border); 183 + background: var(--color-base); 184 + color: var(--color-text); 185 + font-family: var(--font-mono); 186 + font-size: 0.875rem; 187 + text-align: center; 188 + } 189 + 190 + .theme-editor-spacing-field input:focus { 191 + outline: none; 192 + border-color: var(--color-primary); 193 + } 194 + 195 + /* Code themes */ 196 + .theme-editor-code-themes { 197 + display: grid; 198 + grid-template-columns: 1fr 1fr; 199 + gap: 0.75rem; 200 + } 201 + 202 + .theme-editor-code-theme { 203 + display: flex; 204 + flex-direction: column; 205 + gap: 0.25rem; 206 + } 207 + 208 + .theme-editor-code-theme label { 209 + font-size: 0.875rem; 210 + color: var(--color-muted); 211 + } 212 + 213 + .theme-editor-code-theme select { 214 + padding: 0.5rem; 215 + border: 1px solid var(--color-border); 216 + background: var(--color-base); 217 + color: var(--color-text); 218 + font-family: var(--font-ui); 219 + font-size: 0.875rem; 220 + } 221 + 222 + .theme-editor-code-theme select:focus { 223 + outline: none; 224 + border-color: var(--color-primary); 225 + } 226 + 227 + /* Mode toggle */ 228 + .theme-editor-mode { 229 + display: flex; 230 + align-items: center; 231 + gap: 1rem; 232 + } 233 + 234 + .theme-editor-mode label { 235 + font-size: 0.875rem; 236 + color: var(--color-muted); 237 + } 238 + 239 + /* Actions row */ 240 + .theme-editor-actions { 241 + display: flex; 242 + justify-content: flex-end; 243 + gap: 0.75rem; 244 + padding-top: 1rem; 245 + border-top: 1px solid var(--color-border); 246 + } 247 + 248 + /* Preview header */ 249 + .theme-editor-preview-header { 250 + display: flex; 251 + align-items: center; 252 + justify-content: space-between; 253 + gap: 1rem; 254 + margin-bottom: 0.5rem; 255 + } 256 + 257 + .theme-editor-preview-header h3 { 258 + margin: 0; 259 + font-size: 1rem; 260 + font-weight: 600; 261 + color: var(--color-muted); 262 + } 263 + 264 + .theme-editor-preview .theme-preview { 265 + padding: 1rem; 266 + border: 1px solid var(--color-border); 267 + max-width: none; 268 + } 269 + 270 + .theme-editor-preview .theme-preview--loading, 271 + .theme-editor-preview .theme-preview--error { 272 + padding: 2rem; 273 + text-align: center; 274 + color: var(--color-muted); 275 + } 276 + 277 + /* Preset selector */ 278 + .theme-editor-preset { 279 + display: flex; 280 + flex-direction: column; 281 + gap: 0.25rem; 282 + } 283 + 284 + .theme-editor-preset label { 285 + font-size: 0.875rem; 286 + color: var(--color-muted); 287 + } 288 + 289 + .theme-editor-preset select { 290 + padding: 0.5rem; 291 + border: 1px solid var(--color-border); 292 + background: var(--color-base); 293 + color: var(--color-text); 294 + font-family: var(--font-ui); 295 + font-size: 0.875rem; 296 + } 297 + 298 + .theme-editor-preset select:focus { 299 + outline: none; 300 + border-color: var(--color-primary); 301 + } 302 + 303 + /* Back link */ 304 + .theme-editor-back { 305 + display: inline-flex; 306 + align-items: center; 307 + gap: 0.5rem; 308 + color: var(--color-link); 309 + text-decoration: none; 310 + font-size: 0.875rem; 311 + margin-bottom: 1rem; 312 + } 313 + 314 + .theme-editor-back:hover { 315 + text-decoration: underline; 316 + } 317 + 318 + /* Select dropdown styling */ 319 + .theme-editor-section select, 320 + .theme-editor-preset select, 321 + .theme-editor-code-theme select { 322 + appearance: none; 323 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%239ca3af' d='M6 8L2 4h8z'/%3E%3C/svg%3E"); 324 + background-repeat: no-repeat; 325 + background-position: right 0.5rem center; 326 + padding-right: 2rem; 327 + cursor: pointer; 328 + } 329 + 330 + .theme-editor-section select option, 331 + .theme-editor-preset select option, 332 + .theme-editor-code-theme select option { 333 + background: var(--color-surface); 334 + color: var(--color-text); 335 + padding: 0.5rem; 336 + }
+172
crates/weaver-app/src/components/css.rs
··· 144 Ok(([(CONTENT_TYPE, "text/css")], css).into_response()) 145 } 146 147 #[cfg(feature = "server")] 148 fn minify_css(css: &str) -> Option<String> { 149 use lightningcss::printer::PrinterOptions;
··· 144 Ok(([(CONTENT_TYPE, "text/css")], css).into_response()) 145 } 146 147 + /// Input for generating theme preview data from 4 base colours. 148 + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] 149 + pub struct ThemePreviewInput { 150 + pub background: String, 151 + pub text: String, 152 + pub primary: String, 153 + pub link: String, 154 + pub light_background: Option<String>, 155 + pub light_text: Option<String>, 156 + pub light_primary: Option<String>, 157 + pub light_link: Option<String>, 158 + pub dark_background: Option<String>, 159 + pub dark_text: Option<String>, 160 + pub dark_primary: Option<String>, 161 + pub dark_link: Option<String>, 162 + pub light_code_theme: String, 163 + pub dark_code_theme: String, 164 + } 165 + 166 + /// Generated 16-colour palette for a single variant. 167 + #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] 168 + pub struct ColourPalette { 169 + pub base: String, 170 + pub surface: String, 171 + pub overlay: String, 172 + pub text: String, 173 + pub muted: String, 174 + pub subtle: String, 175 + pub emphasis: String, 176 + pub primary: String, 177 + pub secondary: String, 178 + pub tertiary: String, 179 + pub error: String, 180 + pub warning: String, 181 + pub success: String, 182 + pub border: String, 183 + pub link: String, 184 + pub highlight: String, 185 + } 186 + 187 + impl ColourPalette { 188 + pub fn to_css_vars(&self) -> String { 189 + // Ensure all colours have # prefix. 190 + let fmt = |c: &str| if c.starts_with('#') { c.to_string() } else { format!("#{}", c) }; 191 + format!( 192 + "--color-base: {}; --color-surface: {}; --color-overlay: {}; \ 193 + --color-text: {}; --color-muted: {}; --color-subtle: {}; \ 194 + --color-emphasis: {}; --color-primary: {}; --color-secondary: {}; \ 195 + --color-tertiary: {}; --color-error: {}; --color-warning: {}; \ 196 + --color-success: {}; --color-border: {}; --color-link: {}; \ 197 + --color-highlight: {};", 198 + fmt(&self.base), fmt(&self.surface), fmt(&self.overlay), 199 + fmt(&self.text), fmt(&self.muted), fmt(&self.subtle), 200 + fmt(&self.emphasis), fmt(&self.primary), fmt(&self.secondary), 201 + fmt(&self.tertiary), fmt(&self.error), fmt(&self.warning), 202 + fmt(&self.success), fmt(&self.border), fmt(&self.link), 203 + fmt(&self.highlight) 204 + ) 205 + } 206 + } 207 + 208 + /// Generated theme preview data: palettes + syntax CSS. 209 + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] 210 + pub struct ThemePreviewOutput { 211 + pub light_palette: ColourPalette, 212 + pub dark_palette: ColourPalette, 213 + pub syntax_css: String, 214 + } 215 + 216 + /// Generate theme preview data from 4 base colours. 217 + #[post("/api/theme-preview")] 218 + pub async fn generate_theme_preview( 219 + input: ThemePreviewInput, 220 + ) -> Result<ThemePreviewOutput, ServerFnError> { 221 + use weaver_renderer::colour_gen::{ThemeInputs, ThemeVariant, detect_variant, generate_palette, generate_counterpart_palette}; 222 + use weaver_renderer::css::generate_syntax_css; 223 + use weaver_renderer::theme::{ResolvedTheme, ThemeDefault, default_fonts, default_spacing, ColourSchemeColours}; 224 + 225 + let light_inputs = ThemeInputs { 226 + background: input.light_background.unwrap_or_else(|| input.background.clone()), 227 + text: input.light_text.unwrap_or_else(|| input.text.clone()), 228 + primary: input.light_primary.unwrap_or_else(|| input.primary.clone()), 229 + link: input.light_link.unwrap_or_else(|| input.link.clone()), 230 + }; 231 + 232 + let dark_inputs = ThemeInputs { 233 + background: input.dark_background.unwrap_or_else(|| input.background.clone()), 234 + text: input.dark_text.unwrap_or_else(|| input.text.clone()), 235 + primary: input.dark_primary.unwrap_or_else(|| input.primary.clone()), 236 + link: input.dark_link.unwrap_or_else(|| input.link.clone()), 237 + }; 238 + 239 + let primary_variant = detect_variant(&input.background) 240 + .map_err(|e| ServerFnError::new(format!("Invalid background colour: {}", e)))?; 241 + 242 + let (light_scheme, dark_scheme): (ColourSchemeColours, ColourSchemeColours) = match primary_variant { 243 + ThemeVariant::Light => { 244 + let light = generate_palette(&light_inputs, ThemeVariant::Light) 245 + .map_err(|e| ServerFnError::new(format!("Failed to generate light palette: {}", e)))?; 246 + let dark = generate_counterpart_palette(&dark_inputs, ThemeVariant::Light) 247 + .map_err(|e| ServerFnError::new(format!("Failed to generate dark palette: {}", e)))?; 248 + (light, dark) 249 + } 250 + ThemeVariant::Dark => { 251 + let dark = generate_palette(&dark_inputs, ThemeVariant::Dark) 252 + .map_err(|e| ServerFnError::new(format!("Failed to generate dark palette: {}", e)))?; 253 + let light = generate_counterpart_palette(&light_inputs, ThemeVariant::Dark) 254 + .map_err(|e| ServerFnError::new(format!("Failed to generate light palette: {}", e)))?; 255 + (light, dark) 256 + } 257 + }; 258 + 259 + let light_code_theme = weaver_renderer::theme::ThemeLightCodeTheme::CodeThemeName(Box::new(input.light_code_theme.into())); 260 + let dark_code_theme = weaver_renderer::theme::ThemeDarkCodeTheme::CodeThemeName(Box::new(input.dark_code_theme.into())); 261 + 262 + let resolved = ResolvedTheme { 263 + default: ThemeDefault::Auto, 264 + dark_scheme: dark_scheme.clone(), 265 + light_scheme: light_scheme.clone(), 266 + fonts: default_fonts(), 267 + spacing: default_spacing(), 268 + dark_code_theme, 269 + light_code_theme, 270 + }; 271 + 272 + let syntax_css = generate_syntax_css(&resolved).await.unwrap_or_default(); 273 + 274 + let light_palette = ColourPalette { 275 + base: light_scheme.base.to_string(), 276 + surface: light_scheme.surface.to_string(), 277 + overlay: light_scheme.overlay.to_string(), 278 + text: light_scheme.text.to_string(), 279 + muted: light_scheme.muted.to_string(), 280 + subtle: light_scheme.subtle.to_string(), 281 + emphasis: light_scheme.emphasis.to_string(), 282 + primary: light_scheme.primary.to_string(), 283 + secondary: light_scheme.secondary.to_string(), 284 + tertiary: light_scheme.tertiary.to_string(), 285 + error: light_scheme.error.to_string(), 286 + warning: light_scheme.warning.to_string(), 287 + success: light_scheme.success.to_string(), 288 + border: light_scheme.border.to_string(), 289 + link: light_scheme.link.to_string(), 290 + highlight: light_scheme.highlight.to_string(), 291 + }; 292 + 293 + let dark_palette = ColourPalette { 294 + base: dark_scheme.base.to_string(), 295 + surface: dark_scheme.surface.to_string(), 296 + overlay: dark_scheme.overlay.to_string(), 297 + text: dark_scheme.text.to_string(), 298 + muted: dark_scheme.muted.to_string(), 299 + subtle: dark_scheme.subtle.to_string(), 300 + emphasis: dark_scheme.emphasis.to_string(), 301 + primary: dark_scheme.primary.to_string(), 302 + secondary: dark_scheme.secondary.to_string(), 303 + tertiary: dark_scheme.tertiary.to_string(), 304 + error: dark_scheme.error.to_string(), 305 + warning: dark_scheme.warning.to_string(), 306 + success: dark_scheme.success.to_string(), 307 + border: dark_scheme.border.to_string(), 308 + link: dark_scheme.link.to_string(), 309 + highlight: dark_scheme.highlight.to_string(), 310 + }; 311 + 312 + Ok(ThemePreviewOutput { 313 + light_palette, 314 + dark_palette, 315 + syntax_css, 316 + }) 317 + } 318 + 319 #[cfg(feature = "server")] 320 fn minify_css(css: &str) -> Option<String> { 321 use lightningcss::printer::PrinterOptions;
+1 -1
crates/weaver-app/src/components/dialog/dialog.css
··· 80 81 @media (width >= 40rem) { 82 .dialog { 83 - max-width: 32rem; 84 text-align: left; 85 } 86 }
··· 80 81 @media (width >= 40rem) { 82 .dialog { 83 + max-width: 56rem; 84 text-align: left; 85 } 86 }
+8
crates/weaver-app/src/components/hex_colour_input.rs
··· 15 /// Placeholder text. 16 #[props(default = "000000".to_string())] 17 pub placeholder: String, 18 } 19 20 /// A hex colour input with a colour preview swatch. ··· 62 .to_uppercase(); 63 props.onchange.call(filtered); 64 }, 65 } 66 } 67 }
··· 15 /// Placeholder text. 16 #[props(default = "000000".to_string())] 17 pub placeholder: String, 18 + /// Callback when input receives focus. 19 + #[props(default)] 20 + pub onfocus: Option<EventHandler<FocusEvent>>, 21 } 22 23 /// A hex colour input with a colour preview swatch. ··· 65 .to_uppercase(); 66 props.onchange.call(filtered); 67 }, 68 + onfocus: move |e| { 69 + if let Some(handler) = &props.onfocus { 70 + handler.call(e); 71 + } 72 + }, 73 } 74 } 75 }
+228 -275
crates/weaver-app/src/components/inline_theme_editor.rs
··· 5 use dioxus::prelude::*; 6 use weaver_renderer::themes::{BUILTIN_CODE_THEMES, BUILTIN_COLOUR_SCHEMES}; 7 8 - use crate::components::collapsible::{Collapsible, CollapsibleContent, CollapsibleTrigger}; 9 use crate::components::HexColourInput; 10 use crate::components::toggle_group::{ToggleGroup, ToggleItem}; 11 12 /// Strip leading # or 0x from hex colour strings. ··· 103 /// Props for InlineThemeEditor. 104 #[derive(Props, Clone, PartialEq)] 105 pub struct InlineThemeEditorProps { 106 - /// Current theme values. 107 - pub values: InlineThemeValues, 108 - /// Callback when any value changes. 109 - pub onchange: EventHandler<InlineThemeValues>, 110 } 111 112 /// Inline theme editor with core colour pickers and code theme selector. 113 #[component] 114 pub fn InlineThemeEditor(props: InlineThemeEditorProps) -> Element { 115 - let values = props.values.clone(); 116 117 rsx! { 118 document::Stylesheet { href: INLINE_THEME_EDITOR_CSS } ··· 120 div { class: "inline-theme-editor", 121 h4 { class: "inline-theme-editor-heading", "Theme" } 122 123 - // Preset selector. 124 - div { class: "inline-theme-editor-presets", 125 - label { "Start from preset:" } 126 - select { 127 - onchange: { 128 - let onchange = props.onchange.clone(); 129 - let values = props.values.clone(); 130 - move |e: Event<FormData>| { 131 - let preset_id = e.value(); 132 - if let Some(scheme) = BUILTIN_COLOUR_SCHEMES.iter().find(|s| s.id == preset_id) { 133 - onchange.call(InlineThemeValues { 134 - background: strip_hex_prefix(&scheme.colours.base), 135 - text: strip_hex_prefix(&scheme.colours.text), 136 - primary: strip_hex_prefix(&scheme.colours.primary), 137 - link: strip_hex_prefix(&scheme.colours.link), 138 - light_code_theme: values.light_code_theme.clone(), 139 - dark_code_theme: values.dark_code_theme.clone(), 140 - default_mode: scheme.variant.to_string(), 141 - ..Default::default() 142 - }); 143 - } 144 } 145 - }, 146 - option { value: "", "Custom" } 147 - for scheme in BUILTIN_COLOUR_SCHEMES.iter() { 148 - option { 149 - value: "{scheme.id}", 150 - "{scheme.name}" 151 } 152 - } 153 - } 154 - } 155 - 156 - // Colour pickers. 157 - div { class: "inline-theme-editor-colours", 158 - HexColourInput { 159 - label: Some("Background".to_string()), 160 - value: values.background.clone(), 161 - onchange: { 162 - let values = props.values.clone(); 163 - let onchange = props.onchange.clone(); 164 - move |val| { 165 - let mut v = values.clone(); 166 - v.background = val; 167 - onchange.call(v); 168 - } 169 - }, 170 - } 171 - HexColourInput { 172 - label: Some("Text".to_string()), 173 - value: values.text.clone(), 174 - onchange: { 175 - let values = props.values.clone(); 176 - let onchange = props.onchange.clone(); 177 - move |val| { 178 - let mut v = values.clone(); 179 - v.text = val; 180 - onchange.call(v); 181 } 182 - }, 183 - } 184 - HexColourInput { 185 - label: Some("Primary".to_string()), 186 - value: values.primary.clone(), 187 - onchange: { 188 - let values = props.values.clone(); 189 - let onchange = props.onchange.clone(); 190 - move |val| { 191 - let mut v = values.clone(); 192 - v.primary = val; 193 - onchange.call(v); 194 } 195 - }, 196 - } 197 - HexColourInput { 198 - label: Some("Link".to_string()), 199 - value: values.link.clone(), 200 - onchange: { 201 - let values = props.values.clone(); 202 - let onchange = props.onchange.clone(); 203 - move |val| { 204 - let mut v = values.clone(); 205 - v.link = val; 206 - onchange.call(v); 207 - } 208 - }, 209 - } 210 - } 211 212 - // Code theme selectors (light and dark). 213 - div { class: "inline-theme-editor-code-theme", 214 - label { "Light code theme:" } 215 - select { 216 - value: "{values.light_code_theme}", 217 - onchange: { 218 - let values = props.values.clone(); 219 - let onchange = props.onchange.clone(); 220 - move |e: Event<FormData>| { 221 - let mut v = values.clone(); 222 - v.light_code_theme = e.value(); 223 - onchange.call(v); 224 - } 225 - }, 226 - for theme in BUILTIN_CODE_THEMES.iter().filter(|t| t.variant == "light") { 227 - option { 228 - value: "{theme.id}", 229 - "{theme.name}" 230 } 231 } 232 } 233 - } 234 - div { class: "inline-theme-editor-code-theme", 235 - label { "Dark code theme:" } 236 - select { 237 - value: "{values.dark_code_theme}", 238 - onchange: { 239 - let values = props.values.clone(); 240 - let onchange = props.onchange.clone(); 241 - move |e: Event<FormData>| { 242 - let mut v = values.clone(); 243 - v.dark_code_theme = e.value(); 244 - onchange.call(v); 245 - } 246 - }, 247 - for theme in BUILTIN_CODE_THEMES.iter().filter(|t| t.variant == "dark") { 248 - option { 249 - value: "{theme.id}", 250 - "{theme.name}" 251 } 252 } 253 - } 254 - } 255 256 - // Default mode toggle group. 257 - // Indexes: 0 = auto, 1 = light, 2 = dark 258 - div { class: "inline-theme-editor-mode", 259 - label { "Default mode:" } 260 - ToggleGroup { 261 - horizontal: true, 262 - default_pressed: mode_to_index(&values.default_mode), 263 - on_pressed_change: { 264 - let values = props.values.clone(); 265 - let onchange = props.onchange.clone(); 266 - move |pressed: std::collections::HashSet<usize>| { 267 - if let Some(&idx) = pressed.iter().next() { 268 - let mut v = values.clone(); 269 - v.default_mode = index_to_mode(idx).to_string(); 270 - onchange.call(v); 271 } 272 } 273 - }, 274 - ToggleItem { index: 0usize, "Auto" } 275 - ToggleItem { index: 1usize, "Light" } 276 - ToggleItem { index: 2usize, "Dark" } 277 } 278 } 279 280 // Advanced section for per-variant colour customization. 281 - Collapsible { 282 - CollapsibleTrigger { "Advanced colour options" } 283 - CollapsibleContent { 284 - div { class: "inline-theme-editor-advanced-content", 285 - // Light variant colours. 286 - div { class: "inline-theme-editor-variant", 287 - h5 { "Light variant" } 288 - div { class: "inline-theme-editor-colours", 289 - HexColourInput { 290 - label: Some("Background".to_string()), 291 - value: values.light_background.clone().unwrap_or_default(), 292 - placeholder: values.background.clone(), 293 - onchange: { 294 - let values = props.values.clone(); 295 - let onchange = props.onchange.clone(); 296 - move |val: String| { 297 - let mut v = values.clone(); 298 - v.light_background = if val.is_empty() { None } else { Some(val) }; 299 - onchange.call(v); 300 - } 301 - }, 302 - } 303 - HexColourInput { 304 - label: Some("Text".to_string()), 305 - value: values.light_text.clone().unwrap_or_default(), 306 - placeholder: values.text.clone(), 307 - onchange: { 308 - let values = props.values.clone(); 309 - let onchange = props.onchange.clone(); 310 - move |val: String| { 311 - let mut v = values.clone(); 312 - v.light_text = if val.is_empty() { None } else { Some(val) }; 313 - onchange.call(v); 314 - } 315 - }, 316 - } 317 - HexColourInput { 318 - label: Some("Primary".to_string()), 319 - value: values.light_primary.clone().unwrap_or_default(), 320 - placeholder: values.primary.clone(), 321 - onchange: { 322 - let values = props.values.clone(); 323 - let onchange = props.onchange.clone(); 324 - move |val: String| { 325 - let mut v = values.clone(); 326 - v.light_primary = if val.is_empty() { None } else { Some(val) }; 327 - onchange.call(v); 328 - } 329 - }, 330 - } 331 - HexColourInput { 332 - label: Some("Link".to_string()), 333 - value: values.light_link.clone().unwrap_or_default(), 334 - placeholder: values.link.clone(), 335 - onchange: { 336 - let values = props.values.clone(); 337 - let onchange = props.onchange.clone(); 338 - move |val: String| { 339 - let mut v = values.clone(); 340 - v.light_link = if val.is_empty() { None } else { Some(val) }; 341 - onchange.call(v); 342 - } 343 - }, 344 } 345 } 346 - } 347 348 - // Dark variant colours. 349 - div { class: "inline-theme-editor-variant", 350 - h5 { "Dark variant" } 351 - div { class: "inline-theme-editor-colours", 352 - HexColourInput { 353 - label: Some("Background".to_string()), 354 - value: values.dark_background.clone().unwrap_or_default(), 355 - placeholder: values.background.clone(), 356 - onchange: { 357 - let values = props.values.clone(); 358 - let onchange = props.onchange.clone(); 359 - move |val: String| { 360 - let mut v = values.clone(); 361 - v.dark_background = if val.is_empty() { None } else { Some(val) }; 362 - onchange.call(v); 363 - } 364 - }, 365 - } 366 - HexColourInput { 367 - label: Some("Text".to_string()), 368 - value: values.dark_text.clone().unwrap_or_default(), 369 - placeholder: values.text.clone(), 370 - onchange: { 371 - let values = props.values.clone(); 372 - let onchange = props.onchange.clone(); 373 - move |val: String| { 374 - let mut v = values.clone(); 375 - v.dark_text = if val.is_empty() { None } else { Some(val) }; 376 - onchange.call(v); 377 - } 378 - }, 379 - } 380 - HexColourInput { 381 - label: Some("Primary".to_string()), 382 - value: values.dark_primary.clone().unwrap_or_default(), 383 - placeholder: values.primary.clone(), 384 - onchange: { 385 - let values = props.values.clone(); 386 - let onchange = props.onchange.clone(); 387 - move |val: String| { 388 - let mut v = values.clone(); 389 - v.dark_primary = if val.is_empty() { None } else { Some(val) }; 390 - onchange.call(v); 391 - } 392 - }, 393 - } 394 - HexColourInput { 395 - label: Some("Link".to_string()), 396 - value: values.dark_link.clone().unwrap_or_default(), 397 - placeholder: values.link.clone(), 398 - onchange: { 399 - let values = props.values.clone(); 400 - let onchange = props.onchange.clone(); 401 - move |val: String| { 402 - let mut v = values.clone(); 403 - v.dark_link = if val.is_empty() { None } else { Some(val) }; 404 - onchange.call(v); 405 - } 406 - }, 407 } 408 } 409 } ··· 411 } 412 } 413 414 - // Link to full editor. 415 - // TODO: Enable once theme page exists 416 - // a { href: "/{ident}/themes", class: "inline-theme-editor-full-link", 417 - // "Edit full theme ->" 418 - // } 419 } 420 } 421 }
··· 5 use dioxus::prelude::*; 6 use weaver_renderer::themes::{BUILTIN_CODE_THEMES, BUILTIN_COLOUR_SCHEMES}; 7 8 use crate::components::HexColourInput; 9 + use crate::components::collapsible::{Collapsible, CollapsibleContent, CollapsibleTrigger}; 10 + use crate::components::theme_preview::ThemePreview; 11 use crate::components::toggle_group::{ToggleGroup, ToggleItem}; 12 13 /// Strip leading # or 0x from hex colour strings. ··· 104 /// Props for InlineThemeEditor. 105 #[derive(Props, Clone, PartialEq)] 106 pub struct InlineThemeEditorProps { 107 + /// Current theme values (signal for reactivity). 108 + pub values: Signal<InlineThemeValues>, 109 + /// Control advanced options visibility. 110 + /// None = auto (container query), Some(true) = always show, Some(false) = always hide. 111 + #[props(default)] 112 + pub show_advanced: Option<bool>, 113 + /// Show live preview of theme. 114 + #[props(default = false)] 115 + pub show_preview: bool, 116 } 117 118 /// Inline theme editor with core colour pickers and code theme selector. 119 #[component] 120 pub fn InlineThemeEditor(props: InlineThemeEditorProps) -> Element { 121 + let mut values = props.values; 122 + 123 + let advanced_class = match props.show_advanced { 124 + Some(true) => "inline-theme-editor-advanced force-show", 125 + Some(false) => "inline-theme-editor-advanced force-hide", 126 + None => "inline-theme-editor-advanced", 127 + }; 128 129 rsx! { 130 document::Stylesheet { href: INLINE_THEME_EDITOR_CSS } ··· 132 div { class: "inline-theme-editor", 133 h4 { class: "inline-theme-editor-heading", "Theme" } 134 135 + // Main theme controls - two columns when wide. 136 + div { class: "inline-theme-editor-main", 137 + // Left column: colours and mode toggle. 138 + div { class: "inline-theme-editor-main-left", 139 + // Colour pickers (2x2 grid). 140 + div { class: "inline-theme-editor-colours", 141 + HexColourInput { 142 + label: Some("Background".to_string()), 143 + value: values().background.clone(), 144 + onchange: move |val| values.write().background = val, 145 } 146 + HexColourInput { 147 + label: Some("Text".to_string()), 148 + value: values().text.clone(), 149 + onchange: move |val| values.write().text = val, 150 } 151 + HexColourInput { 152 + label: Some("Primary".to_string()), 153 + value: values().primary.clone(), 154 + onchange: move |val| values.write().primary = val, 155 } 156 + HexColourInput { 157 + label: Some("Link".to_string()), 158 + value: values().link.clone(), 159 + onchange: move |val| values.write().link = val, 160 } 161 + } 162 163 + // Default mode toggle group. 164 + div { class: "inline-theme-editor-mode", 165 + label { "Default mode:" } 166 + ToggleGroup { 167 + horizontal: true, 168 + default_pressed: mode_to_index(&values().default_mode), 169 + on_pressed_change: move |pressed: std::collections::HashSet<usize>| { 170 + if let Some(&idx) = pressed.iter().next() { 171 + values.write().default_mode = index_to_mode(idx).to_string(); 172 + } 173 + }, 174 + ToggleItem { index: 0usize, "Auto" } 175 + ToggleItem { index: 1usize, "Light" } 176 + ToggleItem { index: 2usize, "Dark" } 177 } 178 } 179 } 180 + 181 + // Right column: preset and code theme dropdowns. 182 + div { class: "inline-theme-editor-main-right", 183 + // Preset selector. 184 + div { class: "inline-theme-editor-presets", 185 + label { "Preset:" } 186 + select { 187 + onchange: move |e: Event<FormData>| { 188 + let preset_id = e.value(); 189 + if let Some(scheme) = BUILTIN_COLOUR_SCHEMES.iter().find(|s| s.id == preset_id) { 190 + let mut v = values.write(); 191 + v.background = strip_hex_prefix(&scheme.colours.base); 192 + v.text = strip_hex_prefix(&scheme.colours.text); 193 + v.primary = strip_hex_prefix(&scheme.colours.primary); 194 + v.link = strip_hex_prefix(&scheme.colours.link); 195 + v.default_mode = scheme.variant.to_string(); 196 + // Clear overrides when selecting preset. 197 + v.light_background = None; 198 + v.light_text = None; 199 + v.light_primary = None; 200 + v.light_link = None; 201 + v.dark_background = None; 202 + v.dark_text = None; 203 + v.dark_primary = None; 204 + v.dark_link = None; 205 + } 206 + }, 207 + option { value: "", "Custom" } 208 + for scheme in BUILTIN_COLOUR_SCHEMES.iter() { 209 + option { 210 + value: "{scheme.id}", 211 + "{scheme.name}" 212 + } 213 + } 214 } 215 } 216 217 + // Code theme selectors. 218 + div { class: "inline-theme-editor-code-theme", 219 + label { "Light code theme:" } 220 + select { 221 + value: "{values().light_code_theme}", 222 + onchange: move |e: Event<FormData>| { 223 + values.write().light_code_theme = e.value(); 224 + }, 225 + for theme in BUILTIN_CODE_THEMES.iter().filter(|t| t.variant == "light") { 226 + option { 227 + value: "{theme.id}", 228 + "{theme.name}" 229 + } 230 } 231 } 232 + } 233 + div { class: "inline-theme-editor-code-theme", 234 + label { "Dark code theme:" } 235 + select { 236 + value: "{values().dark_code_theme}", 237 + onchange: move |e: Event<FormData>| { 238 + values.write().dark_code_theme = e.value(); 239 + }, 240 + for theme in BUILTIN_CODE_THEMES.iter().filter(|t| t.variant == "dark") { 241 + option { 242 + value: "{theme.id}", 243 + "{theme.name}" 244 + } 245 + } 246 + } 247 + } 248 } 249 } 250 251 // Advanced section for per-variant colour customization. 252 + div { class: "{advanced_class}", 253 + Collapsible { 254 + CollapsibleTrigger { "Advanced colour options" } 255 + CollapsibleContent { 256 + div { class: "inline-theme-editor-advanced-content", 257 + // Light variant colours. 258 + div { class: "inline-theme-editor-variant", 259 + h5 { "Light variant" } 260 + div { class: "inline-theme-editor-colours", 261 + HexColourInput { 262 + label: Some("Background".to_string()), 263 + value: values().light_background.clone().unwrap_or_default(), 264 + placeholder: values().background.clone(), 265 + onchange: move |val: String| { 266 + values.write().light_background = if val.is_empty() { None } else { Some(val) }; 267 + }, 268 + } 269 + HexColourInput { 270 + label: Some("Text".to_string()), 271 + value: values().light_text.clone().unwrap_or_default(), 272 + placeholder: values().text.clone(), 273 + onchange: move |val: String| { 274 + values.write().light_text = if val.is_empty() { None } else { Some(val) }; 275 + }, 276 + } 277 + HexColourInput { 278 + label: Some("Primary".to_string()), 279 + value: values().light_primary.clone().unwrap_or_default(), 280 + placeholder: values().primary.clone(), 281 + onchange: move |val: String| { 282 + values.write().light_primary = if val.is_empty() { None } else { Some(val) }; 283 + }, 284 + } 285 + HexColourInput { 286 + label: Some("Link".to_string()), 287 + value: values().light_link.clone().unwrap_or_default(), 288 + placeholder: values().link.clone(), 289 + onchange: move |val: String| { 290 + values.write().light_link = if val.is_empty() { None } else { Some(val) }; 291 + }, 292 + } 293 } 294 } 295 296 + // Dark variant colours. 297 + div { class: "inline-theme-editor-variant", 298 + h5 { "Dark variant" } 299 + div { class: "inline-theme-editor-colours", 300 + HexColourInput { 301 + label: Some("Background".to_string()), 302 + value: values().dark_background.clone().unwrap_or_default(), 303 + placeholder: values().background.clone(), 304 + onchange: move |val: String| { 305 + values.write().dark_background = if val.is_empty() { None } else { Some(val) }; 306 + }, 307 + } 308 + HexColourInput { 309 + label: Some("Text".to_string()), 310 + value: values().dark_text.clone().unwrap_or_default(), 311 + placeholder: values().text.clone(), 312 + onchange: move |val: String| { 313 + values.write().dark_text = if val.is_empty() { None } else { Some(val) }; 314 + }, 315 + } 316 + HexColourInput { 317 + label: Some("Primary".to_string()), 318 + value: values().dark_primary.clone().unwrap_or_default(), 319 + placeholder: values().primary.clone(), 320 + onchange: move |val: String| { 321 + values.write().dark_primary = if val.is_empty() { None } else { Some(val) }; 322 + }, 323 + } 324 + HexColourInput { 325 + label: Some("Link".to_string()), 326 + value: values().dark_link.clone().unwrap_or_default(), 327 + placeholder: values().link.clone(), 328 + onchange: move |val: String| { 329 + values.write().dark_link = if val.is_empty() { None } else { Some(val) }; 330 + }, 331 + } 332 } 333 } 334 } ··· 336 } 337 } 338 339 + // Live preview section. 340 + if props.show_preview { 341 + PreviewSection { values } 342 + } 343 + } 344 + } 345 + } 346 + 347 + /// Preview section with light/dark toggle. 348 + #[component] 349 + fn PreviewSection(values: Signal<InlineThemeValues>) -> Element { 350 + let mut preview_dark = use_signal(|| false); 351 + 352 + rsx! { 353 + div { class: "inline-theme-editor-preview", 354 + div { class: "inline-theme-editor-preview-header", 355 + h5 { "Preview" } 356 + ToggleGroup { 357 + horizontal: true, 358 + default_pressed: std::collections::HashSet::from([0usize]), 359 + on_pressed_change: move |pressed: std::collections::HashSet<usize>| { 360 + if let Some(&idx) = pressed.iter().next() { 361 + preview_dark.set(idx == 1); 362 + } 363 + }, 364 + ToggleItem { index: 0usize, "Light" } 365 + ToggleItem { index: 1usize, "Dark" } 366 + } 367 + } 368 + ThemePreview { 369 + values, 370 + dark: preview_dark(), 371 + } 372 } 373 } 374 }
+7 -1
crates/weaver-app/src/components/mod.rs
··· 9 }; 10 11 pub mod css; 12 - pub use css::NotebookCss; 13 14 mod entry; 15 #[allow(unused_imports)] ··· 366 mod inline_theme_editor; 367 pub use inline_theme_editor::{InlineThemeEditor, InlineThemeValues}; 368 369 mod notebook_editor; 370 pub use notebook_editor::{NotebookEditor, NotebookEditorMode, NotebookFormState}; 371
··· 9 }; 10 11 pub mod css; 12 + pub use css::{ColourPalette, NotebookCss, ThemePreviewInput, ThemePreviewOutput, generate_theme_preview}; 13 14 mod entry; 15 #[allow(unused_imports)] ··· 366 mod inline_theme_editor; 367 pub use inline_theme_editor::{InlineThemeEditor, InlineThemeValues}; 368 369 + mod theme_preview; 370 + pub use theme_preview::ThemePreview; 371 + 372 + mod theme_editor; 373 + pub use theme_editor::{ThemeEditor, ThemeEditorValues, ColourSchemeValues}; 374 + 375 mod notebook_editor; 376 pub use notebook_editor::{NotebookEditor, NotebookEditorMode, NotebookFormState}; 377
+174 -79
crates/weaver-app/src/components/notebook_editor.rs
··· 25 pub tags: Vec<String>, 26 pub tags_input: String, 27 pub content_warnings: Vec<String>, 28 pub rating: Option<String>, 29 pub theme: InlineThemeValues, 30 } ··· 52 .as_ref() 53 .map(|cw| cw.iter().map(|s| s.to_string()).collect()) 54 .unwrap_or_default(), 55 rating: book.rating.as_ref().map(|r| r.to_string()), 56 theme: theme.unwrap_or_default(), 57 } ··· 76 /// Error message to display. 77 #[props(default)] 78 pub error: Option<String>, 79 } 80 81 /// Notebook create/edit form. ··· 83 pub fn NotebookEditor(props: NotebookEditorProps) -> Element { 84 let mut state = use_signal(|| props.initial_state.clone().unwrap_or_default()); 85 86 let save_label = match props.mode { 87 NotebookEditorMode::Create => "Create", 88 NotebookEditorMode::Edit => "Save", ··· 108 109 div { class: "notebook-editor", 110 div { class: "notebook-editor-form", 111 - // Title field. 112 - div { class: "notebook-editor-field", 113 - label { "Title" } 114 - input { 115 - r#type: "text", 116 - value: "{state.read().title}", 117 - placeholder: "My Notebook", 118 - oninput: move |e| { 119 - state.write().title = e.value(); 120 - }, 121 - } 122 - } 123 - 124 - // Path field. 125 - div { class: "notebook-editor-field", 126 - label { "Path" } 127 - input { 128 - r#type: "text", 129 - value: "{auto_path}", 130 - placeholder: "my-notebook", 131 - oninput: move |e| { 132 - state.write().path = e.value(); 133 - }, 134 - } 135 - span { class: "notebook-editor-hint", 136 - "URL-friendly identifier. Auto-generated from title if empty." 137 - } 138 - } 139 140 - // Publish global toggle. 141 - div { class: "notebook-editor-field notebook-editor-toggle", 142 - label { 143 - input { 144 - r#type: "checkbox", 145 - checked: state.read().publish_global, 146 - onchange: move |e| { 147 - state.write().publish_global = e.checked(); 148 - }, 149 } 150 - " Publish globally" 151 - } 152 - span { class: "notebook-editor-hint", 153 - "Enable site.standard.* records for cross-platform discovery." 154 } 155 - } 156 157 - // Tags field. 158 - div { class: "notebook-editor-field", 159 - label { "Tags" } 160 - div { class: "notebook-editor-tags", 161 - for (i, tag) in state.read().tags.iter().enumerate() { 162 - span { 163 - key: "{i}", 164 - class: "notebook-editor-tag", 165 - "{tag}" 166 - button { 167 - class: "notebook-editor-tag-remove", 168 - onclick: move |_| { 169 - state.write().tags.remove(i); 170 }, 171 - "ร—" 172 } 173 } 174 } 175 - input { 176 - r#type: "text", 177 - class: "notebook-editor-tags-input", 178 - value: "{state.read().tags_input}", 179 - placeholder: "Add tag...", 180 - oninput: move |e| { 181 - state.write().tags_input = e.value(); 182 - }, 183 - onkeydown: move |e| { 184 - if e.key() == Key::Enter || e.key() == Key::Character(",".to_string()) { 185 - e.prevent_default(); 186 - let tag = state.read().tags_input.trim().to_string(); 187 - if !tag.is_empty() { 188 - let mut s = state.write(); 189 - if !s.tags.contains(&tag) { 190 - s.tags.push(tag); 191 } 192 - s.tags_input.clear(); 193 } 194 } 195 - }, 196 } 197 } 198 } 199 200 // Theme editor. 201 InlineThemeEditor { 202 - values: state.read().theme.clone(), 203 - onchange: move |v| { 204 - state.write().theme = v; 205 - }, 206 } 207 208 // Error display. ··· 215 Button { 216 variant: ButtonVariant::Primary, 217 onclick: move |_| { 218 - props.on_save.call(state.read().clone()); 219 }, 220 disabled: !can_save || props.saving, 221 if props.saving { "Saving..." } else { "{save_label}" }
··· 25 pub tags: Vec<String>, 26 pub tags_input: String, 27 pub content_warnings: Vec<String>, 28 + pub content_warnings_input: String, 29 pub rating: Option<String>, 30 pub theme: InlineThemeValues, 31 } ··· 53 .as_ref() 54 .map(|cw| cw.iter().map(|s| s.to_string()).collect()) 55 .unwrap_or_default(), 56 + content_warnings_input: String::new(), 57 rating: book.rating.as_ref().map(|r| r.to_string()), 58 theme: theme.unwrap_or_default(), 59 } ··· 78 /// Error message to display. 79 #[props(default)] 80 pub error: Option<String>, 81 + /// Control advanced theme options visibility. 82 + /// None = auto (container query), Some(true) = always show, Some(false) = always hide. 83 + #[props(default)] 84 + pub show_advanced_theme: Option<bool>, 85 + /// Show content settings (content warnings, rating). 86 + #[props(default = false)] 87 + pub show_content_settings: bool, 88 } 89 90 /// Notebook create/edit form. ··· 92 pub fn NotebookEditor(props: NotebookEditorProps) -> Element { 93 let mut state = use_signal(|| props.initial_state.clone().unwrap_or_default()); 94 95 + // Separate signal for theme editor (InlineThemeEditor writes directly to this). 96 + let theme_values = use_signal(|| state.read().theme.clone()); 97 + 98 let save_label = match props.mode { 99 NotebookEditorMode::Create => "Create", 100 NotebookEditorMode::Edit => "Save", ··· 120 121 div { class: "notebook-editor", 122 div { class: "notebook-editor-form", 123 + // Top section - two columns when wide. 124 + div { class: "notebook-editor-top", 125 + // Left column: title, path. 126 + div { class: "notebook-editor-top-left", 127 + // Title field. 128 + div { class: "notebook-editor-field", 129 + label { "Title" } 130 + input { 131 + r#type: "text", 132 + value: "{state.read().title}", 133 + placeholder: "My Notebook", 134 + oninput: move |e| { 135 + state.write().title = e.value(); 136 + }, 137 + } 138 + } 139 140 + // Path field. 141 + div { class: "notebook-editor-field", 142 + label { "Path" } 143 + input { 144 + r#type: "text", 145 + value: "{auto_path}", 146 + placeholder: "my-notebook", 147 + oninput: move |e| { 148 + state.write().path = e.value(); 149 + }, 150 + } 151 + span { class: "notebook-editor-hint", 152 + "URL-friendly identifier. Auto-generated from title if empty." 153 + } 154 } 155 } 156 157 + // Right column: publish globally, tags. 158 + div { class: "notebook-editor-top-right", 159 + // Publish global toggle. 160 + div { class: "notebook-editor-field notebook-editor-toggle", 161 + label { 162 + input { 163 + r#type: "checkbox", 164 + checked: state.read().publish_global, 165 + onchange: move |e| { 166 + state.write().publish_global = e.checked(); 167 }, 168 } 169 + " Publish globally" 170 + } 171 + span { class: "notebook-editor-hint", 172 + "Enable site.standard.* records for cross-platform discovery." 173 } 174 } 175 + 176 + // Tags field. 177 + div { class: "notebook-editor-field", 178 + label { "Tags" } 179 + div { class: "notebook-editor-tags", 180 + for (i, tag) in state.read().tags.iter().enumerate() { 181 + span { 182 + key: "{i}", 183 + class: "notebook-editor-tag", 184 + "{tag}" 185 + button { 186 + class: "notebook-editor-tag-remove", 187 + onclick: move |_| { 188 + state.write().tags.remove(i); 189 + }, 190 + "ร—" 191 } 192 } 193 } 194 + input { 195 + r#type: "text", 196 + class: "notebook-editor-tags-input", 197 + value: "{state.read().tags_input}", 198 + placeholder: "Add tag...", 199 + oninput: move |e| { 200 + state.write().tags_input = e.value(); 201 + }, 202 + onkeydown: move |e| { 203 + if e.key() == Key::Enter || e.key() == Key::Character(",".to_string()) { 204 + e.prevent_default(); 205 + let tag = state.read().tags_input.trim().to_string(); 206 + if !tag.is_empty() { 207 + let mut s = state.write(); 208 + if !s.tags.contains(&tag) { 209 + s.tags.push(tag); 210 + } 211 + s.tags_input.clear(); 212 + } 213 + } 214 + }, 215 + } 216 + } 217 } 218 } 219 } 220 221 // Theme editor. 222 InlineThemeEditor { 223 + values: theme_values, 224 + show_advanced: props.show_advanced_theme, 225 + show_preview: true, 226 + } 227 + 228 + // Content settings (optional). 229 + if props.show_content_settings { 230 + div { class: "notebook-editor-content-settings", 231 + h4 { "Content Settings" } 232 + 233 + // Content warnings field. 234 + div { class: "notebook-editor-field", 235 + label { "Content Warnings" } 236 + div { class: "notebook-editor-tags", 237 + for (i, warning) in state.read().content_warnings.iter().enumerate() { 238 + span { 239 + key: "{i}", 240 + class: "notebook-editor-tag notebook-editor-warning", 241 + "{warning}" 242 + button { 243 + class: "notebook-editor-tag-remove", 244 + onclick: move |_| { 245 + state.write().content_warnings.remove(i); 246 + }, 247 + "ร—" 248 + } 249 + } 250 + } 251 + input { 252 + r#type: "text", 253 + class: "notebook-editor-tags-input", 254 + value: "{state.read().content_warnings_input}", 255 + placeholder: "Add warning...", 256 + oninput: move |e| { 257 + state.write().content_warnings_input = e.value(); 258 + }, 259 + onkeydown: move |e| { 260 + if e.key() == Key::Enter || e.key() == Key::Character(",".to_string()) { 261 + e.prevent_default(); 262 + let warning = state.read().content_warnings_input.trim().to_string(); 263 + if !warning.is_empty() { 264 + let mut s = state.write(); 265 + if !s.content_warnings.contains(&warning) { 266 + s.content_warnings.push(warning); 267 + } 268 + s.content_warnings_input.clear(); 269 + } 270 + } 271 + }, 272 + } 273 + } 274 + span { class: "notebook-editor-hint", 275 + "Add content warnings for sensitive material (e.g., violence, adult themes)." 276 + } 277 + } 278 + 279 + // Rating field. 280 + div { class: "notebook-editor-field", 281 + label { "Content Rating" } 282 + select { 283 + value: state.read().rating.clone().unwrap_or_default(), 284 + onchange: move |e: Event<FormData>| { 285 + let val = e.value(); 286 + state.write().rating = if val.is_empty() { None } else { Some(val) }; 287 + }, 288 + option { value: "", "None" } 289 + option { value: "general", "General" } 290 + option { value: "teen", "Teen" } 291 + option { value: "mature", "Mature" } 292 + option { value: "adult", "Adult" } 293 + } 294 + span { class: "notebook-editor-hint", 295 + "Age-appropriateness rating for your notebook's content." 296 + } 297 + } 298 + } 299 } 300 301 // Error display. ··· 308 Button { 309 variant: ButtonVariant::Primary, 310 onclick: move |_| { 311 + let mut form_state = state.read().clone(); 312 + form_state.theme = theme_values(); 313 + props.on_save.call(form_state); 314 }, 315 disabled: !can_save || props.saving, 316 if props.saving { "Saving..." } else { "{save_label}" }
+608
crates/weaver-app/src/components/theme_editor.rs
···
··· 1 + //! Full theme editor component with all 16 colours, fonts, spacing. 2 + 3 + use dioxus::prelude::*; 4 + use weaver_renderer::themes::{BUILTIN_CODE_THEMES, BUILTIN_COLOUR_SCHEMES}; 5 + 6 + use crate::components::HexColourInput; 7 + use crate::components::button::{Button, ButtonVariant}; 8 + use crate::components::inline_theme_editor::InlineThemeValues; 9 + use crate::components::select::{Select, SelectList, SelectOption, SelectTrigger, SelectValue}; 10 + use crate::components::theme_preview::ThemePreview; 11 + use crate::components::toggle_group::{ToggleGroup, ToggleItem}; 12 + 13 + const THEME_EDITOR_CSS: Asset = asset!("/assets/styling/theme-editor.css"); 14 + 15 + /// Full theme values for the theme editor. 16 + #[derive(Debug, Clone, PartialEq, Default)] 17 + pub struct ThemeEditorValues { 18 + // Light scheme colours (all 16). 19 + pub light: ColourSchemeValues, 20 + // Dark scheme colours (all 16). 21 + pub dark: ColourSchemeValues, 22 + // Fonts. 23 + pub font_body: String, 24 + pub font_heading: String, 25 + pub font_mono: String, 26 + // Spacing. 27 + pub spacing_base: String, 28 + pub spacing_line_height: String, 29 + pub spacing_scale: String, 30 + // Code themes. 31 + pub light_code_theme: String, 32 + pub dark_code_theme: String, 33 + // Default mode. 34 + pub default_mode: String, 35 + } 36 + 37 + /// All 16 colour values for a single scheme. 38 + #[derive(Debug, Clone, PartialEq, Default)] 39 + pub struct ColourSchemeValues { 40 + pub base: String, 41 + pub surface: String, 42 + pub overlay: String, 43 + pub text: String, 44 + pub muted: String, 45 + pub subtle: String, 46 + pub emphasis: String, 47 + pub primary: String, 48 + pub secondary: String, 49 + pub tertiary: String, 50 + pub error: String, 51 + pub warning: String, 52 + pub success: String, 53 + pub border: String, 54 + pub link: String, 55 + pub highlight: String, 56 + } 57 + 58 + impl ThemeEditorValues { 59 + /// Convert to InlineThemeValues for preview (uses primary 4 colours from light scheme). 60 + pub fn to_inline_values(&self) -> InlineThemeValues { 61 + InlineThemeValues { 62 + background: self.light.base.clone(), 63 + text: self.light.text.clone(), 64 + primary: self.light.primary.clone(), 65 + link: self.light.link.clone(), 66 + light_background: Some(self.light.base.clone()), 67 + light_text: Some(self.light.text.clone()), 68 + light_primary: Some(self.light.primary.clone()), 69 + light_link: Some(self.light.link.clone()), 70 + dark_background: Some(self.dark.base.clone()), 71 + dark_text: Some(self.dark.text.clone()), 72 + dark_primary: Some(self.dark.primary.clone()), 73 + dark_link: Some(self.dark.link.clone()), 74 + light_code_theme: self.light_code_theme.clone(), 75 + dark_code_theme: self.dark_code_theme.clone(), 76 + default_mode: self.default_mode.clone(), 77 + } 78 + } 79 + 80 + /// Create from a preset colour scheme. 81 + pub fn from_preset(scheme_id: &str) -> Option<Self> { 82 + let scheme = BUILTIN_COLOUR_SCHEMES.iter().find(|s| s.id == scheme_id)?; 83 + let colours = &scheme.colours; 84 + 85 + let scheme_values = ColourSchemeValues { 86 + base: strip_hex_prefix(&colours.base), 87 + surface: strip_hex_prefix(&colours.surface), 88 + overlay: strip_hex_prefix(&colours.overlay), 89 + text: strip_hex_prefix(&colours.text), 90 + muted: strip_hex_prefix(&colours.muted), 91 + subtle: strip_hex_prefix(&colours.subtle), 92 + emphasis: strip_hex_prefix(&colours.emphasis), 93 + primary: strip_hex_prefix(&colours.primary), 94 + secondary: strip_hex_prefix(&colours.secondary), 95 + tertiary: strip_hex_prefix(&colours.tertiary), 96 + error: strip_hex_prefix(&colours.error), 97 + warning: strip_hex_prefix(&colours.warning), 98 + success: strip_hex_prefix(&colours.success), 99 + border: strip_hex_prefix(&colours.border), 100 + link: strip_hex_prefix(&colours.link), 101 + highlight: strip_hex_prefix(&colours.highlight), 102 + }; 103 + 104 + // Find counterpart scheme for dark/light. 105 + let is_dark = scheme.variant == "dark"; 106 + let counterpart_id = if is_dark { 107 + "rose-pine-dawn" 108 + } else { 109 + "rose-pine" 110 + }; 111 + let counterpart = BUILTIN_COLOUR_SCHEMES 112 + .iter() 113 + .find(|s| s.id == counterpart_id); 114 + 115 + let counterpart_values = counterpart 116 + .map(|c| { 117 + let colours = &c.colours; 118 + ColourSchemeValues { 119 + base: strip_hex_prefix(&colours.base), 120 + surface: strip_hex_prefix(&colours.surface), 121 + overlay: strip_hex_prefix(&colours.overlay), 122 + text: strip_hex_prefix(&colours.text), 123 + muted: strip_hex_prefix(&colours.muted), 124 + subtle: strip_hex_prefix(&colours.subtle), 125 + emphasis: strip_hex_prefix(&colours.emphasis), 126 + primary: strip_hex_prefix(&colours.primary), 127 + secondary: strip_hex_prefix(&colours.secondary), 128 + tertiary: strip_hex_prefix(&colours.tertiary), 129 + error: strip_hex_prefix(&colours.error), 130 + warning: strip_hex_prefix(&colours.warning), 131 + success: strip_hex_prefix(&colours.success), 132 + border: strip_hex_prefix(&colours.border), 133 + link: strip_hex_prefix(&colours.link), 134 + highlight: strip_hex_prefix(&colours.highlight), 135 + } 136 + }) 137 + .unwrap_or_default(); 138 + 139 + let (light, dark) = if is_dark { 140 + (counterpart_values, scheme_values) 141 + } else { 142 + (scheme_values, counterpart_values) 143 + }; 144 + 145 + Some(Self { 146 + light, 147 + dark, 148 + font_body: String::new(), 149 + font_heading: String::new(), 150 + font_mono: String::new(), 151 + spacing_base: "16".to_string(), 152 + spacing_line_height: "1.6".to_string(), 153 + spacing_scale: "1.25".to_string(), 154 + light_code_theme: "rose-pine-dawn".to_string(), 155 + dark_code_theme: "rose-pine".to_string(), 156 + default_mode: scheme.variant.to_string(), 157 + }) 158 + } 159 + } 160 + 161 + fn strip_hex_prefix(s: &str) -> String { 162 + s.trim_start_matches('#') 163 + .trim_start_matches("0x") 164 + .trim_start_matches("0X") 165 + .to_uppercase() 166 + } 167 + 168 + fn mode_to_index(mode: &str) -> std::collections::HashSet<usize> { 169 + let idx = match mode { 170 + "light" => 1, 171 + "dark" => 2, 172 + _ => 0, 173 + }; 174 + std::collections::HashSet::from([idx]) 175 + } 176 + 177 + fn index_to_mode(idx: usize) -> &'static str { 178 + match idx { 179 + 1 => "light", 180 + 2 => "dark", 181 + _ => "auto", 182 + } 183 + } 184 + 185 + /// Props for ThemeEditor. 186 + #[derive(Props, Clone, PartialEq)] 187 + pub struct ThemeEditorProps { 188 + /// Current theme values (signal for reactivity). 189 + pub values: Signal<ThemeEditorValues>, 190 + /// Callback on save. 191 + pub on_save: EventHandler<ThemeEditorValues>, 192 + /// Callback on cancel. 193 + pub on_cancel: EventHandler<()>, 194 + /// Whether save is in progress. 195 + #[props(default = false)] 196 + pub saving: bool, 197 + } 198 + 199 + /// Full theme editor with all 16 colours, fonts, spacing. 200 + #[component] 201 + pub fn ThemeEditor(props: ThemeEditorProps) -> Element { 202 + let mut values = props.values; 203 + 204 + // Preview values derived from editor values. 205 + let mut preview_values = use_signal(|| values().to_inline_values()); 206 + 207 + // Sync preview when values change. 208 + use_effect(move || { 209 + let v = values(); 210 + preview_values.set(v.to_inline_values()); 211 + }); 212 + 213 + // Which variant is being edited (for preview). 214 + let mut editing_dark = use_signal(|| false); 215 + 216 + // Derived signals for light/dark schemes. 217 + let light_scheme = use_memo(move || values().light.clone()); 218 + let dark_scheme = use_memo(move || values().dark.clone()); 219 + 220 + rsx! { 221 + document::Stylesheet { href: THEME_EDITOR_CSS } 222 + 223 + div { class: "theme-editor-page", 224 + // Left column: controls. 225 + div { class: "theme-editor-controls", 226 + // Mode toggle. 227 + ModeSection { values } 228 + 229 + // Fonts section. 230 + FontsSection { values } 231 + 232 + // Spacing section. 233 + SpacingSection { values } 234 + 235 + // Code themes. 236 + CodeThemesSection { values } 237 + 238 + // Light scheme colours. 239 + ColourSchemeSection { 240 + title: "Light scheme", 241 + variant: "light", 242 + scheme: light_scheme, 243 + on_change: move |new_scheme: ColourSchemeValues| { 244 + values.write().light = new_scheme; 245 + }, 246 + on_focus: move |_| editing_dark.set(false), 247 + } 248 + 249 + // Dark scheme colours. 250 + ColourSchemeSection { 251 + title: "Dark scheme", 252 + variant: "dark", 253 + scheme: dark_scheme, 254 + on_change: move |new_scheme: ColourSchemeValues| { 255 + values.write().dark = new_scheme; 256 + }, 257 + on_focus: move |_| editing_dark.set(true), 258 + } 259 + 260 + // Actions. 261 + div { class: "theme-editor-actions", 262 + Button { 263 + variant: ButtonVariant::Primary, 264 + onclick: move |_| { 265 + props.on_save.call(values()); 266 + }, 267 + disabled: props.saving, 268 + if props.saving { "Saving..." } else { "Save" } 269 + } 270 + Button { 271 + variant: ButtonVariant::Ghost, 272 + onclick: move |_| { 273 + props.on_cancel.call(()); 274 + }, 275 + disabled: props.saving, 276 + "Cancel" 277 + } 278 + } 279 + } 280 + 281 + // Right column: preview. 282 + div { class: "theme-editor-preview", 283 + div { class: "theme-editor-preview-header", 284 + h3 { "Preview" } 285 + ToggleGroup { 286 + horizontal: true, 287 + default_pressed: std::collections::HashSet::from([0usize]), 288 + on_pressed_change: move |pressed: std::collections::HashSet<usize>| { 289 + if let Some(&idx) = pressed.iter().next() { 290 + editing_dark.set(idx == 1); 291 + } 292 + }, 293 + ToggleItem { index: 0usize, "Light" } 294 + ToggleItem { index: 1usize, "Dark" } 295 + } 296 + } 297 + ThemePreview { 298 + values: preview_values, 299 + dark: editing_dark(), 300 + } 301 + } 302 + } 303 + } 304 + } 305 + 306 + /// Colour scheme section with all 16 colours and its own preset selector. 307 + #[component] 308 + fn ColourSchemeSection( 309 + title: &'static str, 310 + variant: &'static str, 311 + scheme: ReadSignal<ColourSchemeValues>, 312 + on_change: EventHandler<ColourSchemeValues>, 313 + on_focus: EventHandler<FocusEvent>, 314 + ) -> Element { 315 + // Helper to create colour input with change handler. 316 + let colour_input = 317 + move |label: &'static str, value: String, setter: fn(&mut ColourSchemeValues, String)| { 318 + let on_change = on_change.clone(); 319 + let on_focus = on_focus.clone(); 320 + let scheme = scheme.clone(); 321 + rsx! { 322 + HexColourInput { 323 + label: Some(label.to_string()), 324 + value: value, 325 + onchange: move |val: String| { 326 + let mut new_scheme = scheme(); 327 + setter(&mut new_scheme, val); 328 + on_change.call(new_scheme); 329 + }, 330 + onfocus: move |e| on_focus.call(e), 331 + } 332 + } 333 + }; 334 + 335 + let s = scheme(); 336 + 337 + // Filter presets by variant. 338 + let presets: Vec<_> = BUILTIN_COLOUR_SCHEMES 339 + .iter() 340 + .filter(|p| p.variant == variant) 341 + .collect(); 342 + 343 + rsx! { 344 + div { class: "theme-editor-section", 345 + div { class: "theme-editor-section-header", 346 + h3 { "{title}" } 347 + Select::<String> { 348 + placeholder: "Custom", 349 + on_value_change: move |val: Option<String>| { 350 + let Some(val) = val else { return }; 351 + let Some(preset) = BUILTIN_COLOUR_SCHEMES.iter().find(|p| p.id == val) else { return }; 352 + let colours = &preset.colours; 353 + on_change.call(ColourSchemeValues { 354 + base: strip_hex_prefix(&colours.base), 355 + surface: strip_hex_prefix(&colours.surface), 356 + overlay: strip_hex_prefix(&colours.overlay), 357 + text: strip_hex_prefix(&colours.text), 358 + muted: strip_hex_prefix(&colours.muted), 359 + subtle: strip_hex_prefix(&colours.subtle), 360 + emphasis: strip_hex_prefix(&colours.emphasis), 361 + primary: strip_hex_prefix(&colours.primary), 362 + secondary: strip_hex_prefix(&colours.secondary), 363 + tertiary: strip_hex_prefix(&colours.tertiary), 364 + error: strip_hex_prefix(&colours.error), 365 + warning: strip_hex_prefix(&colours.warning), 366 + success: strip_hex_prefix(&colours.success), 367 + border: strip_hex_prefix(&colours.border), 368 + link: strip_hex_prefix(&colours.link), 369 + highlight: strip_hex_prefix(&colours.highlight), 370 + }); 371 + }, 372 + SelectTrigger { 373 + SelectValue {} 374 + } 375 + SelectList { 376 + for (idx, preset) in presets.iter().enumerate() { 377 + SelectOption::<String> { 378 + index: idx, 379 + value: preset.id.to_string(), 380 + text_value: preset.name.to_string(), 381 + "{preset.name}" 382 + } 383 + } 384 + } 385 + } 386 + } 387 + 388 + // Background colours. 389 + div { class: "theme-editor-colour-group", 390 + span { class: "theme-editor-colour-group-label", "Background" } 391 + div { class: "theme-editor-colour-group-items", 392 + {colour_input("Base", s.base.clone(), |s, v| s.base = v)} 393 + {colour_input("Surface", s.surface.clone(), |s, v| s.surface = v)} 394 + {colour_input("Overlay", s.overlay.clone(), |s, v| s.overlay = v)} 395 + } 396 + } 397 + 398 + // Text colours. 399 + div { class: "theme-editor-colour-group", 400 + span { class: "theme-editor-colour-group-label", "Text" } 401 + div { class: "theme-editor-colour-group-items", 402 + {colour_input("Text", s.text.clone(), |s, v| s.text = v)} 403 + {colour_input("Muted", s.muted.clone(), |s, v| s.muted = v)} 404 + {colour_input("Subtle", s.subtle.clone(), |s, v| s.subtle = v)} 405 + {colour_input("Emphasis", s.emphasis.clone(), |s, v| s.emphasis = v)} 406 + } 407 + } 408 + 409 + // Accent colours. 410 + div { class: "theme-editor-colour-group", 411 + span { class: "theme-editor-colour-group-label", "Accents" } 412 + div { class: "theme-editor-colour-group-items", 413 + {colour_input("Primary", s.primary.clone(), |s, v| s.primary = v)} 414 + {colour_input("Secondary", s.secondary.clone(), |s, v| s.secondary = v)} 415 + {colour_input("Tertiary", s.tertiary.clone(), |s, v| s.tertiary = v)} 416 + {colour_input("Link", s.link.clone(), |s, v| s.link = v)} 417 + } 418 + } 419 + 420 + // Status colours. 421 + div { class: "theme-editor-colour-group", 422 + span { class: "theme-editor-colour-group-label", "Status" } 423 + div { class: "theme-editor-colour-group-items", 424 + {colour_input("Error", s.error.clone(), |s, v| s.error = v)} 425 + {colour_input("Warning", s.warning.clone(), |s, v| s.warning = v)} 426 + {colour_input("Success", s.success.clone(), |s, v| s.success = v)} 427 + } 428 + } 429 + 430 + // UI colours. 431 + div { class: "theme-editor-colour-group", 432 + span { class: "theme-editor-colour-group-label", "UI" } 433 + div { class: "theme-editor-colour-group-items", 434 + {colour_input("Border", s.border.clone(), |s, v| s.border = v)} 435 + {colour_input("Highlight", s.highlight.clone(), |s, v| s.highlight = v)} 436 + } 437 + } 438 + } 439 + } 440 + } 441 + 442 + /// Fonts section. 443 + #[component] 444 + fn FontsSection(values: Signal<ThemeEditorValues>) -> Element { 445 + rsx! { 446 + div { class: "theme-editor-section", 447 + h3 { "Fonts" } 448 + div { class: "theme-editor-fonts", 449 + div { class: "theme-editor-font-field", 450 + label { "Body" } 451 + input { 452 + r#type: "text", 453 + value: "{values().font_body}", 454 + placeholder: "IBM Plex Serif", 455 + oninput: move |e| values.write().font_body = e.value(), 456 + } 457 + } 458 + div { class: "theme-editor-font-field", 459 + label { "Heading" } 460 + input { 461 + r#type: "text", 462 + value: "{values().font_heading}", 463 + placeholder: "IBM Plex Sans", 464 + oninput: move |e| values.write().font_heading = e.value(), 465 + } 466 + } 467 + div { class: "theme-editor-font-field", 468 + label { "Monospace" } 469 + input { 470 + r#type: "text", 471 + value: "{values().font_mono}", 472 + placeholder: "IBM Plex Mono", 473 + oninput: move |e| values.write().font_mono = e.value(), 474 + } 475 + } 476 + } 477 + } 478 + } 479 + } 480 + 481 + /// Spacing section. 482 + #[component] 483 + fn SpacingSection(values: Signal<ThemeEditorValues>) -> Element { 484 + rsx! { 485 + div { class: "theme-editor-section", 486 + h3 { "Spacing" } 487 + div { class: "theme-editor-spacing", 488 + div { class: "theme-editor-spacing-field", 489 + label { "Base size (px)" } 490 + input { 491 + r#type: "number", 492 + value: "{values().spacing_base}", 493 + oninput: move |e| values.write().spacing_base = e.value(), 494 + } 495 + } 496 + div { class: "theme-editor-spacing-field", 497 + label { "Line height" } 498 + input { 499 + r#type: "text", 500 + value: "{values().spacing_line_height}", 501 + oninput: move |e| values.write().spacing_line_height = e.value(), 502 + } 503 + } 504 + div { class: "theme-editor-spacing-field", 505 + label { "Scale" } 506 + input { 507 + r#type: "text", 508 + value: "{values().spacing_scale}", 509 + oninput: move |e| values.write().spacing_scale = e.value(), 510 + } 511 + } 512 + } 513 + } 514 + } 515 + } 516 + 517 + /// Code themes section. 518 + #[component] 519 + fn CodeThemesSection(values: Signal<ThemeEditorValues>) -> Element { 520 + let light_themes: Vec<_> = BUILTIN_CODE_THEMES 521 + .iter() 522 + .filter(|t| t.variant == "light") 523 + .collect(); 524 + let dark_themes: Vec<_> = BUILTIN_CODE_THEMES 525 + .iter() 526 + .filter(|t| t.variant == "dark") 527 + .collect(); 528 + 529 + rsx! { 530 + div { class: "theme-editor-section", 531 + h3 { "Code themes" } 532 + div { class: "theme-editor-code-themes", 533 + div { class: "theme-editor-code-theme", 534 + label { "Light" } 535 + Select::<String> { 536 + value: Some(values().light_code_theme.clone()), 537 + on_value_change: move |val: Option<String>| { 538 + if let Some(val) = val { 539 + values.write().light_code_theme = val; 540 + } 541 + }, 542 + SelectTrigger { 543 + SelectValue {} 544 + } 545 + SelectList { 546 + for (idx, theme) in light_themes.iter().enumerate() { 547 + SelectOption::<String> { 548 + index: idx, 549 + value: theme.id.to_string(), 550 + text_value: theme.name.to_string(), 551 + "{theme.name}" 552 + } 553 + } 554 + } 555 + } 556 + } 557 + div { class: "theme-editor-code-theme", 558 + label { "Dark" } 559 + Select::<String> { 560 + value: Some(values().dark_code_theme.clone()), 561 + on_value_change: move |val: Option<String>| { 562 + if let Some(val) = val { 563 + values.write().dark_code_theme = val; 564 + } 565 + }, 566 + SelectTrigger { 567 + SelectValue {} 568 + } 569 + SelectList { 570 + for (idx, theme) in dark_themes.iter().enumerate() { 571 + SelectOption::<String> { 572 + index: idx, 573 + value: theme.id.to_string(), 574 + text_value: theme.name.to_string(), 575 + "{theme.name}" 576 + } 577 + } 578 + } 579 + } 580 + } 581 + } 582 + } 583 + } 584 + } 585 + 586 + /// Mode section. 587 + #[component] 588 + fn ModeSection(values: Signal<ThemeEditorValues>) -> Element { 589 + rsx! { 590 + div { class: "theme-editor-section", 591 + div { class: "theme-editor-mode", 592 + label { "Default mode:" } 593 + ToggleGroup { 594 + horizontal: true, 595 + default_pressed: mode_to_index(&values().default_mode), 596 + on_pressed_change: move |pressed: std::collections::HashSet<usize>| { 597 + if let Some(&idx) = pressed.iter().next() { 598 + values.write().default_mode = index_to_mode(idx).to_string(); 599 + } 600 + }, 601 + ToggleItem { index: 0usize, "Auto" } 602 + ToggleItem { index: 1usize, "Light" } 603 + ToggleItem { index: 2usize, "Dark" } 604 + } 605 + } 606 + } 607 + } 608 + }
+149
crates/weaver-app/src/components/theme_preview.rs
···
··· 1 + //! Theme preview component with sample markdown rendering. 2 + 3 + use dioxus::prelude::*; 4 + 5 + use crate::components::css::{ThemePreviewInput, generate_theme_preview}; 6 + use crate::components::inline_theme_editor::InlineThemeValues; 7 + 8 + const NOTEBOOK_DEFAULTS_CSS: Asset = asset!("/assets/styling/notebook-defaults.css"); 9 + 10 + /// Sample markdown to render for theme preview. 11 + const SAMPLE_MARKDOWN: &str = r#"# Heading 1 12 + 13 + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 14 + 15 + ## Heading 2 16 + 17 + Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris. Here's a [link to somewhere](#) and some **bold text** with *italics*. 18 + 19 + ### Heading 3 20 + 21 + > A blockquote for emphasis. This tests the border and muted text colours. 22 + 23 + Here's a list of items: 24 + 25 + - First item with some text 26 + - Second item with `inline code` 27 + - Third item 28 + 29 + And some numbered steps: 30 + 31 + 1. Do the first thing 32 + 2. Then the second 33 + 3. Finally the third 34 + 35 + ```rust 36 + fn main() { 37 + // A code block to test syntax highlighting 38 + let message = "Hello, world!"; 39 + println!("{}", message); 40 + } 41 + ``` 42 + 43 + --- 44 + 45 + That's the end of the preview. 46 + "#; 47 + 48 + /// Render markdown to HTML using the full pipeline with syntax highlighting. 49 + fn render_markdown(markdown: &str) -> String { 50 + use markdown_weaver::Parser; 51 + use weaver_renderer::atproto::ClientWriter; 52 + use weaver_renderer::default_md_options; 53 + 54 + let parser = Parser::new_ext(markdown, default_md_options()).into_offset_iter(); 55 + let mut html = String::new(); 56 + ClientWriter::<_, _, ()>::new(parser, &mut html, markdown) 57 + .run() 58 + .ok(); 59 + html 60 + } 61 + 62 + /// Props for ThemePreview with signal for reactivity. 63 + #[derive(Props, Clone, PartialEq)] 64 + pub struct ThemePreviewProps { 65 + /// Theme values to preview (signal for reactivity). 66 + pub values: Signal<InlineThemeValues>, 67 + /// Whether to show dark variant (false = light). 68 + #[props(default = false)] 69 + pub dark: bool, 70 + } 71 + 72 + /// Theme preview component that renders sample markdown with theme applied. 73 + #[component] 74 + pub fn ThemePreview(props: ThemePreviewProps) -> Element { 75 + let dark = props.dark; 76 + let values = props.values; 77 + 78 + let mut preview_resource = use_resource(move || { 79 + let values = values(); 80 + async move { 81 + // Skip if background is empty (invalid). 82 + if values.background.is_empty() { 83 + return Err(ServerFnError::new("No theme values set")); 84 + } 85 + 86 + let input = ThemePreviewInput { 87 + background: values.background.clone(), 88 + text: values.text.clone(), 89 + primary: values.primary.clone(), 90 + link: values.link.clone(), 91 + light_background: values.light_background.clone(), 92 + light_text: values.light_text.clone(), 93 + light_primary: values.light_primary.clone(), 94 + light_link: values.light_link.clone(), 95 + dark_background: values.dark_background.clone(), 96 + dark_text: values.dark_text.clone(), 97 + dark_primary: values.dark_primary.clone(), 98 + dark_link: values.dark_link.clone(), 99 + light_code_theme: values.light_code_theme.clone(), 100 + dark_code_theme: values.dark_code_theme.clone(), 101 + }; 102 + generate_theme_preview(input).await 103 + } 104 + }); 105 + 106 + // Restart resource when values change. 107 + use_effect(move || { 108 + let _ = values(); 109 + preview_resource.restart(); 110 + }); 111 + 112 + let rendered_html = render_markdown(SAMPLE_MARKDOWN); 113 + 114 + match preview_resource() { 115 + Some(Ok(output)) => { 116 + let palette = if dark { &output.dark_palette } else { &output.light_palette }; 117 + let css_vars = palette.to_css_vars(); 118 + 119 + // Scoped CSS: variables -> notebook defaults -> syntax highlighting 120 + let scoped_css = format!(".theme-preview {{ {} }}", css_vars); 121 + 122 + rsx! { 123 + // 1. CSS variables scoped to .theme-preview 124 + style { dangerous_inner_html: "{scoped_css}" } 125 + 126 + // 2. Notebook content styles (uses the variables) 127 + document::Stylesheet { href: NOTEBOOK_DEFAULTS_CSS } 128 + 129 + // 3. Syntax highlighting CSS 130 + style { dangerous_inner_html: "{output.syntax_css}" } 131 + 132 + div { 133 + class: "theme-preview notebook-content", 134 + dangerous_inner_html: "{rendered_html}" 135 + } 136 + } 137 + } 138 + Some(Err(e)) => rsx! { 139 + div { class: "theme-preview theme-preview--error", 140 + "Failed to generate theme preview: {e}" 141 + } 142 + }, 143 + None => rsx! { 144 + div { class: "theme-preview theme-preview--loading", 145 + "Loading preview..." 146 + } 147 + }, 148 + } 149 + }
+3 -3
crates/weaver-app/src/custom_domain_app.rs
··· 80 } 81 82 #[component] 83 - fn EntryByRkey(rkey: ReadOnlySignal<SmolStr>) -> Element { 84 let ctx = use_context::<CustomDomainContext>(); 85 86 rsx! { ··· 93 } 94 95 #[component] 96 - fn EntryEdit(rkey: ReadOnlySignal<SmolStr>) -> Element { 97 let ctx = use_context::<CustomDomainContext>(); 98 99 rsx! { ··· 113 } 114 115 #[component] 116 - fn PathPage(segments: ReadOnlySignal<Vec<String>>) -> Element { 117 let ctx = use_context::<CustomDomainContext>(); 118 119 let ident = use_memo(move || ctx.owner.clone());
··· 80 } 81 82 #[component] 83 + fn EntryByRkey(rkey: ReadSignal<SmolStr>) -> Element { 84 let ctx = use_context::<CustomDomainContext>(); 85 86 rsx! { ··· 93 } 94 95 #[component] 96 + fn EntryEdit(rkey: ReadSignal<SmolStr>) -> Element { 97 let ctx = use_context::<CustomDomainContext>(); 98 99 rsx! { ··· 113 } 114 115 #[component] 116 + fn PathPage(segments: ReadSignal<Vec<String>>) -> Element { 117 let ctx = use_context::<CustomDomainContext>(); 118 119 let ident = use_memo(move || ctx.owner.clone());
+6 -3
crates/weaver-app/src/lib.rs
··· 48 use views::{ 49 AboutPage, Callback, DraftEdit, DraftsList, Editor, Home, InvitesPage, LeafletEntry, 50 LeafletEntryNsid, Navbar, NewDraft, Notebook, NotebookEntryByRkey, NotebookEntryEdit, 51 - NotebookIndex, NotebookPage, PcktEntry, PcktEntryBlogNsid, PcktEntryNsid, PrivacyPage, 52 - RecordIndex, RecordPage, StandaloneEntry, StandaloneEntryEdit, StandaloneEntryNsid, TermsPage, 53 - WhiteWindEntry, WhiteWindEntryNsid, 54 }; 55 56 #[derive(Debug, Clone, Routable, PartialEq)] ··· 118 #[layout(Notebook)] 119 #[route("/")] 120 NotebookIndex { ident: AtIdentifier<'static>, book_title: SmolStr }, 121 #[route("/:title")] 122 EntryPage { ident: AtIdentifier<'static>, book_title: SmolStr, title: SmolStr }, 123 // Entry by rkey (canonical path)
··· 48 use views::{ 49 AboutPage, Callback, DraftEdit, DraftsList, Editor, Home, InvitesPage, LeafletEntry, 50 LeafletEntryNsid, Navbar, NewDraft, Notebook, NotebookEntryByRkey, NotebookEntryEdit, 51 + NotebookIndex, NotebookPage, NotebookSettings, PcktEntry, PcktEntryBlogNsid, PcktEntryNsid, 52 + PrivacyPage, RecordIndex, RecordPage, StandaloneEntry, StandaloneEntryEdit, 53 + StandaloneEntryNsid, TermsPage, WhiteWindEntry, WhiteWindEntryNsid, 54 }; 55 56 #[derive(Debug, Clone, Routable, PartialEq)] ··· 118 #[layout(Notebook)] 119 #[route("/")] 120 NotebookIndex { ident: AtIdentifier<'static>, book_title: SmolStr }, 121 + // Settings must come before /:title to avoid capture 122 + #[route("/settings")] 123 + NotebookSettings { ident: AtIdentifier<'static>, book_title: SmolStr }, 124 #[route("/:title")] 125 EntryPage { ident: AtIdentifier<'static>, book_title: SmolStr, title: SmolStr }, 126 // Entry by rkey (canonical path)
+1
crates/weaver-app/src/views/footer.rs
··· 37 | Route::StandaloneEntryNsid { ident, .. } 38 | Route::StandaloneEntryEdit { ident, .. } 39 | Route::NotebookIndex { ident, .. } 40 | Route::EntryPage { ident, .. } 41 | Route::NotebookEntryByRkey { ident, .. } 42 | Route::NotebookEntryEdit { ident, .. } => is_owner_ident(ident),
··· 37 | Route::StandaloneEntryNsid { ident, .. } 38 | Route::StandaloneEntryEdit { ident, .. } 39 | Route::NotebookIndex { ident, .. } 40 + | Route::NotebookSettings { ident, .. } 41 | Route::EntryPage { ident, .. } 42 | Route::NotebookEntryByRkey { ident, .. } 43 | Route::NotebookEntryEdit { ident, .. } => is_owner_ident(ident),
+3
crates/weaver-app/src/views/mod.rs
··· 52 53 mod subdomain_navbar; 54 pub use subdomain_navbar::{SubdomainErrorLayout, SubdomainNavbar};
··· 52 53 mod subdomain_navbar; 54 pub use subdomain_navbar::{SubdomainErrorLayout, SubdomainNavbar}; 55 + 56 + mod notebook_settings; 57 + pub use notebook_settings::NotebookSettings;
+960
crates/weaver-app/src/views/notebook_settings.rs
···
··· 1 + //! Notebook settings view with full theme editor. 2 + 3 + use dioxus::prelude::*; 4 + use jacquard::client::AgentSessionExt; 5 + use jacquard::common::from_data; 6 + use jacquard::smol_str::SmolStr; 7 + use jacquard::types::aturi::AtUri; 8 + use jacquard::types::ident::AtIdentifier; 9 + use jacquard::types::string::Datetime; 10 + use jacquard::{CowStr, IntoStatic}; 11 + use weaver_api::com_atproto::repo::strong_ref::StrongRef; 12 + use weaver_api::sh_weaver::notebook::book::Book; 13 + use weaver_api::sh_weaver::notebook::colour_scheme::ColourScheme; 14 + use weaver_api::sh_weaver::notebook::theme::{ 15 + Theme, ThemeDarkCodeTheme, ThemeFonts, ThemeLightCodeTheme, ThemeSpacing, 16 + }; 17 + 18 + use crate::Route; 19 + use crate::auth::AuthState; 20 + use crate::components::button::{Button, ButtonVariant}; 21 + use crate::components::notebook::{delete_publication, sync_publication}; 22 + use crate::components::{ColourSchemeValues, ThemeEditor, ThemeEditorValues}; 23 + use crate::data; 24 + use crate::fetch::Fetcher; 25 + 26 + const NOTEBOOK_SETTINGS_CSS: Asset = asset!("/assets/styling/notebook-settings.css"); 27 + 28 + /// Form state for notebook settings. 29 + #[derive(Debug, Clone, PartialEq, Default)] 30 + pub struct NotebookSettingsState { 31 + pub title: String, 32 + pub path: String, 33 + pub publish_global: bool, 34 + pub tags: Vec<String>, 35 + pub tags_input: String, 36 + pub content_warnings: Vec<String>, 37 + pub rating: Option<String>, 38 + pub theme: ThemeEditorValues, 39 + } 40 + 41 + /// Props for NotebookSettings view. 42 + #[derive(Props, Clone, PartialEq)] 43 + pub struct NotebookSettingsProps { 44 + pub ident: ReadSignal<AtIdentifier<'static>>, 45 + pub book_title: ReadSignal<SmolStr>, 46 + } 47 + 48 + /// Notebook settings page with full theme editor. 49 + #[component] 50 + pub fn NotebookSettings(props: NotebookSettingsProps) -> Element { 51 + // Load notebook data. 52 + let (notebook_result, notebook_data) = data::use_notebook(props.ident, props.book_title); 53 + 54 + #[cfg(feature = "fullstack-server")] 55 + let _ = notebook_result?; 56 + 57 + let auth_state = use_context::<Signal<AuthState>>(); 58 + let fetcher = use_context::<Fetcher>(); 59 + let navigator = use_navigator(); 60 + 61 + // Form state - editable copy of initial values. 62 + let mut state = use_signal(NotebookSettingsState::default); 63 + let mut state_initialized = use_signal(|| false); 64 + let mut theme_values = use_signal(|| ThemeEditorValues::from_preset("rose-pine").unwrap_or_default()); 65 + let mut theme_initialized = use_signal(|| false); 66 + let mut saving = use_signal(|| false); 67 + let mut error = use_signal(|| None::<String>); 68 + 69 + // Active section for navigation. 70 + let mut active_section = use_signal(|| "general".to_string()); 71 + 72 + // Check ownership. 73 + let current_did = auth_state.read().did.clone(); 74 + let ident_val = props.ident.read().clone(); 75 + let is_owner = match (&current_did, &ident_val) { 76 + (Some(did), AtIdentifier::Did(ident_did)) => *did == *ident_did, 77 + _ => false, 78 + }; 79 + 80 + // Derive notebook URI and book from loaded data. 81 + let notebook_uri = use_memo(move || { 82 + let data = notebook_data()?; 83 + let (notebook_view, _) = &data; 84 + Some(notebook_view.uri.clone().into_static()) 85 + }); 86 + 87 + let current_book = use_memo(move || { 88 + let data = notebook_data()?; 89 + let (notebook_view, _) = &data; 90 + let book: Book<'_> = from_data(&notebook_view.record).ok()?; 91 + Some(book.into_static()) 92 + }); 93 + 94 + // Derive initial form state from notebook data. 95 + let initial_form_state = use_memo(move || { 96 + let book = current_book()?; 97 + Some(NotebookSettingsState { 98 + title: book 99 + .title 100 + .as_ref() 101 + .map(|t| t.as_ref().to_string()) 102 + .unwrap_or_default(), 103 + path: book 104 + .path 105 + .as_ref() 106 + .map(|p| p.as_ref().to_string()) 107 + .unwrap_or_default(), 108 + publish_global: book.publish_global.unwrap_or(false), 109 + tags: book 110 + .tags 111 + .as_ref() 112 + .map(|t| t.iter().map(|s| s.as_ref().to_string()).collect()) 113 + .unwrap_or_default(), 114 + tags_input: String::new(), 115 + content_warnings: book 116 + .content_warnings 117 + .as_ref() 118 + .map(|cw| cw.iter().map(|s| s.as_ref().to_string()).collect()) 119 + .unwrap_or_default(), 120 + rating: book.rating.as_ref().map(|r| r.as_ref().to_string()), 121 + theme: ThemeEditorValues::default(), 122 + }) 123 + }); 124 + 125 + // Load theme values from theme ref. 126 + let theme_fetcher = fetcher.clone(); 127 + let theme_resource = use_resource(move || { 128 + let theme_ref = current_book() 129 + .and_then(|b| b.theme.clone()) 130 + .map(|t| t.into_static()); 131 + let fetcher = theme_fetcher.clone(); 132 + async move { 133 + let Some(theme_ref) = theme_ref else { 134 + return None; 135 + }; 136 + load_full_theme_values(&fetcher, &theme_ref).await.ok() 137 + } 138 + }); 139 + 140 + // Initialize editable state from loaded data (once). 141 + use_effect(move || { 142 + if !state_initialized() { 143 + if let Some(form_state) = initial_form_state() { 144 + state.set(form_state); 145 + state_initialized.set(true); 146 + } 147 + } 148 + if !theme_initialized() { 149 + if let Some(Some(theme_vals)) = theme_resource.read().as_ref() { 150 + theme_values.set(theme_vals.clone()); 151 + theme_initialized.set(true); 152 + } 153 + } 154 + }); 155 + 156 + if !is_owner { 157 + return rsx! { 158 + div { class: "notebook-settings-unauthorized", 159 + h1 { "Unauthorized" } 160 + p { "You don't have permission to edit this notebook's settings." } 161 + } 162 + }; 163 + } 164 + 165 + // Save general settings handler. 166 + let save_fetcher = fetcher.clone(); 167 + let handle_save = move |_| { 168 + let fetcher = save_fetcher.clone(); 169 + let uri = notebook_uri(); 170 + let book = current_book(); 171 + 172 + spawn(async move { 173 + let Some(uri) = uri else { 174 + error.set(Some("Notebook not loaded".to_string())); 175 + return; 176 + }; 177 + let Some(existing_book) = book else { 178 + error.set(Some("Notebook not loaded".to_string())); 179 + return; 180 + }; 181 + 182 + saving.set(true); 183 + error.set(None); 184 + 185 + let form = state(); 186 + let now = Datetime::now(); 187 + 188 + let tags: Option<Vec<CowStr<'static>>> = if form.tags.is_empty() { 189 + None 190 + } else { 191 + Some(form.tags.iter().map(|s| CowStr::from(s.clone())).collect()) 192 + }; 193 + 194 + use weaver_api::sh_weaver::notebook::{ContentRating, ContentWarning}; 195 + 196 + let content_warnings: Option<Vec<ContentWarning<'static>>> = 197 + if form.content_warnings.is_empty() { 198 + None 199 + } else { 200 + Some( 201 + form.content_warnings 202 + .iter() 203 + .map(|s| ContentWarning::from(s.clone())) 204 + .collect(), 205 + ) 206 + }; 207 + 208 + let path: CowStr<'static> = form.path.clone().into(); 209 + let title: CowStr<'static> = form.title.clone().into(); 210 + let publish_global = form.publish_global; 211 + let rating: Option<ContentRating<'static>> = 212 + form.rating.clone().map(|r| ContentRating::from(r)); 213 + 214 + let client = fetcher.get_client(); 215 + match client 216 + .update_record::<Book>(&uri, |book| { 217 + book.title = Some(title.clone()); 218 + if !path.is_empty() { 219 + book.path = Some(path.clone()); 220 + } 221 + book.publish_global = Some(publish_global); 222 + book.tags = tags.clone(); 223 + book.content_warnings = content_warnings.clone(); 224 + book.rating = rating.clone(); 225 + book.updated_at = Some(now.clone()); 226 + }) 227 + .await 228 + { 229 + Ok(_) => { 230 + // Sync or delete publication based on publish_global. 231 + let theme_vals = crate::components::InlineThemeValues::default(); 232 + if publish_global { 233 + if let Err(e) = 234 + sync_publication(&fetcher, &uri, &title, &path, &theme_vals).await 235 + { 236 + tracing::warn!("Failed to sync publication: {:?}", e); 237 + } 238 + } else if let Err(e) = delete_publication(&fetcher, &uri).await { 239 + tracing::warn!("Failed to delete publication: {:?}", e); 240 + } 241 + saving.set(false); 242 + } 243 + Err(e) => { 244 + error.set(Some(format!("Failed to save: {:?}", e))); 245 + saving.set(false); 246 + } 247 + } 248 + }); 249 + }; 250 + 251 + rsx! { 252 + document::Stylesheet { href: NOTEBOOK_SETTINGS_CSS } 253 + 254 + div { class: "notebook-settings", 255 + // Sidebar navigation. 256 + nav { class: "notebook-settings-nav", 257 + button { 258 + class: if active_section() == "general" { "active" } else { "" }, 259 + onclick: move |_| active_section.set("general".to_string()), 260 + "General" 261 + } 262 + button { 263 + class: if active_section() == "theme" { "active" } else { "" }, 264 + onclick: move |_| active_section.set("theme".to_string()), 265 + "Theme" 266 + } 267 + button { 268 + class: if active_section() == "collaborators" { "active" } else { "" }, 269 + onclick: move |_| active_section.set("collaborators".to_string()), 270 + "Collaborators" 271 + } 272 + button { 273 + class: if active_section() == "danger" { "active" } else { "" }, 274 + onclick: move |_| active_section.set("danger".to_string()), 275 + "Danger Zone" 276 + } 277 + } 278 + 279 + // Content area. 280 + div { class: "notebook-settings-content", 281 + match active_section().as_str() { 282 + "general" => rsx! { 283 + GeneralSection { 284 + state: state, 285 + saving: saving(), 286 + error: error(), 287 + on_save: handle_save, 288 + } 289 + }, 290 + "theme" => rsx! { 291 + ThemeSection { 292 + values: theme_values, 293 + saving: saving(), 294 + on_save: { 295 + let fetcher = fetcher.clone(); 296 + move |values: ThemeEditorValues| { 297 + let fetcher = fetcher.clone(); 298 + let uri = notebook_uri(); 299 + let book = current_book(); 300 + let values = values.clone(); 301 + 302 + spawn(async move { 303 + let Some(uri) = uri else { 304 + error.set(Some("Notebook not loaded".to_string())); 305 + return; 306 + }; 307 + let Some(existing_book) = book else { 308 + error.set(Some("Notebook not loaded".to_string())); 309 + return; 310 + }; 311 + 312 + saving.set(true); 313 + error.set(None); 314 + 315 + // Sync theme records. 316 + match sync_full_theme(&fetcher, existing_book.theme.as_ref(), &values).await { 317 + Ok(theme_result) => { 318 + // Update book with new theme ref. 319 + let theme_ref = StrongRef::new() 320 + .uri(theme_result.theme_uri) 321 + .cid(theme_result.theme_cid) 322 + .build(); 323 + 324 + let client = fetcher.get_client(); 325 + let now = Datetime::now(); 326 + match client 327 + .update_record::<Book>(&uri, |book| { 328 + book.theme = Some(theme_ref.clone()); 329 + book.updated_at = Some(now.clone()); 330 + }) 331 + .await 332 + { 333 + Ok(_) => { 334 + theme_values.set(values); 335 + saving.set(false); 336 + } 337 + Err(e) => { 338 + error.set(Some(format!("Failed to update book: {:?}", e))); 339 + saving.set(false); 340 + } 341 + } 342 + } 343 + Err(e) => { 344 + error.set(Some(format!("Failed to sync theme: {:?}", e))); 345 + saving.set(false); 346 + } 347 + } 348 + }); 349 + } 350 + }, 351 + } 352 + }, 353 + "collaborators" => rsx! { 354 + CollaboratorsSection {} 355 + }, 356 + "danger" => rsx! { 357 + DangerSection { 358 + notebook_uri: notebook_uri(), 359 + on_deleted: { 360 + let ident = ident_val.clone(); 361 + move |_| { 362 + navigator.push(Route::RepositoryIndex { ident: ident.clone() }); 363 + } 364 + }, 365 + } 366 + }, 367 + _ => rsx! { div { "Unknown section" } }, 368 + } 369 + } 370 + } 371 + } 372 + } 373 + 374 + /// General settings section. 375 + #[component] 376 + fn GeneralSection( 377 + state: Signal<NotebookSettingsState>, 378 + saving: bool, 379 + error: Option<String>, 380 + on_save: EventHandler<()>, 381 + ) -> Element { 382 + let mut state = state; 383 + 384 + rsx! { 385 + div { class: "notebook-settings-section", 386 + h2 { "General Settings" } 387 + 388 + // Title field. 389 + div { class: "notebook-settings-field", 390 + label { "Title" } 391 + input { 392 + r#type: "text", 393 + value: "{state.read().title}", 394 + placeholder: "My Notebook", 395 + oninput: move |e| state.write().title = e.value(), 396 + } 397 + } 398 + 399 + // Path field. 400 + div { class: "notebook-settings-field", 401 + label { "Path" } 402 + input { 403 + r#type: "text", 404 + value: "{state.read().path}", 405 + placeholder: "my-notebook", 406 + oninput: move |e| state.write().path = e.value(), 407 + } 408 + span { class: "notebook-settings-hint", 409 + "URL-friendly identifier for your notebook." 410 + } 411 + } 412 + 413 + // Publish globally toggle. 414 + div { class: "notebook-settings-field notebook-settings-toggle", 415 + label { 416 + input { 417 + r#type: "checkbox", 418 + checked: state.read().publish_global, 419 + onchange: move |e| state.write().publish_global = e.checked(), 420 + } 421 + " Publish globally" 422 + } 423 + span { class: "notebook-settings-hint", 424 + "Enable cross-platform discovery via site.standard.* records." 425 + } 426 + } 427 + 428 + // Tags field. 429 + div { class: "notebook-settings-field", 430 + label { "Tags" } 431 + div { class: "notebook-settings-tags", 432 + for (i, tag) in state.read().tags.iter().enumerate() { 433 + span { 434 + key: "{i}", 435 + class: "notebook-settings-tag", 436 + "{tag}" 437 + button { 438 + class: "notebook-settings-tag-remove", 439 + onclick: move |_| { 440 + state.write().tags.remove(i); 441 + }, 442 + "ร—" 443 + } 444 + } 445 + } 446 + input { 447 + r#type: "text", 448 + class: "notebook-settings-tags-input", 449 + value: "{state.read().tags_input}", 450 + placeholder: "Add tag...", 451 + oninput: move |e| state.write().tags_input = e.value(), 452 + onkeydown: move |e| { 453 + if e.key() == Key::Enter || e.key() == Key::Character(",".to_string()) { 454 + e.prevent_default(); 455 + let tag = state.read().tags_input.trim().to_string(); 456 + if !tag.is_empty() { 457 + let mut s = state.write(); 458 + if !s.tags.contains(&tag) { 459 + s.tags.push(tag); 460 + } 461 + s.tags_input.clear(); 462 + } 463 + } 464 + }, 465 + } 466 + } 467 + } 468 + 469 + // Content rating. 470 + div { class: "notebook-settings-field", 471 + label { "Content Rating" } 472 + select { 473 + value: state.read().rating.clone().unwrap_or_default(), 474 + onchange: move |e: Event<FormData>| { 475 + let val = e.value(); 476 + state.write().rating = if val.is_empty() { None } else { Some(val) }; 477 + }, 478 + option { value: "", "None" } 479 + option { value: "general", "General" } 480 + option { value: "mature", "Mature" } 481 + option { value: "adult", "Adult" } 482 + } 483 + } 484 + 485 + // Error display. 486 + if let Some(ref err) = error { 487 + div { class: "notebook-settings-error", "{err}" } 488 + } 489 + 490 + // Save button. 491 + div { class: "notebook-settings-actions", 492 + Button { 493 + variant: ButtonVariant::Primary, 494 + onclick: move |_| on_save.call(()), 495 + disabled: saving, 496 + if saving { "Saving..." } else { "Save Changes" } 497 + } 498 + } 499 + } 500 + } 501 + } 502 + 503 + /// Theme settings section with full editor. 504 + #[component] 505 + fn ThemeSection( 506 + values: Signal<ThemeEditorValues>, 507 + saving: bool, 508 + on_save: EventHandler<ThemeEditorValues>, 509 + ) -> Element { 510 + rsx! { 511 + div { class: "notebook-settings-section notebook-settings-theme", 512 + h2 { "Theme Settings" } 513 + p { class: "notebook-settings-description", 514 + "Customize the appearance of your notebook with colours, fonts, and spacing." 515 + } 516 + 517 + ThemeEditor { 518 + values: values, 519 + on_save: on_save, 520 + on_cancel: move |_| {}, 521 + saving: saving, 522 + } 523 + } 524 + } 525 + } 526 + 527 + /// Collaborators section. 528 + #[component] 529 + fn CollaboratorsSection() -> Element { 530 + rsx! { 531 + div { class: "notebook-settings-section", 532 + h2 { "Collaborators" } 533 + p { class: "notebook-settings-description", 534 + "Manage who can edit this notebook." 535 + } 536 + 537 + // TODO: Integrate CollaboratorsPanel when notebook URI is available. 538 + div { class: "notebook-settings-placeholder", 539 + "Collaborator management coming soon." 540 + } 541 + } 542 + } 543 + } 544 + 545 + /// Danger zone section. 546 + #[component] 547 + fn DangerSection(notebook_uri: Option<AtUri<'static>>, on_deleted: EventHandler<()>) -> Element { 548 + let fetcher = use_context::<Fetcher>(); 549 + let mut show_delete_confirm = use_signal(|| false); 550 + let mut deleting = use_signal(|| false); 551 + let mut delete_error = use_signal(|| None::<String>); 552 + 553 + let delete_fetcher = fetcher.clone(); 554 + let notebook_uri_for_delete = notebook_uri.clone(); 555 + let handle_delete = move |_| { 556 + let Some(uri) = notebook_uri_for_delete.clone() else { 557 + delete_error.set(Some("Notebook not loaded".to_string())); 558 + return; 559 + }; 560 + let fetcher = delete_fetcher.clone(); 561 + 562 + spawn(async move { 563 + deleting.set(true); 564 + delete_error.set(None); 565 + 566 + // Delete all entries first, then the book. 567 + let rkey = match uri.rkey() { 568 + Some(r) => match RecordKey::any(r.as_ref()) { 569 + Ok(k) => k.into_static(), 570 + Err(_) => { 571 + delete_error.set(Some("Invalid record key".to_string())); 572 + deleting.set(false); 573 + return; 574 + } 575 + }, 576 + None => { 577 + delete_error.set(Some("Invalid notebook URI".to_string())); 578 + deleting.set(false); 579 + return; 580 + } 581 + }; 582 + 583 + let client = fetcher.get_client(); 584 + match client.delete_record::<Book>(rkey).await { 585 + Ok(_) => { 586 + deleting.set(false); 587 + show_delete_confirm.set(false); 588 + on_deleted.call(()); 589 + } 590 + Err(e) => { 591 + delete_error.set(Some(format!("Failed to delete: {:?}", e))); 592 + deleting.set(false); 593 + } 594 + } 595 + }); 596 + }; 597 + 598 + rsx! { 599 + div { class: "notebook-settings-section notebook-settings-danger", 600 + h2 { "Danger Zone" } 601 + 602 + if let Some(ref err) = delete_error() { 603 + div { class: "notebook-settings-error", "{err}" } 604 + } 605 + 606 + div { class: "notebook-settings-danger-item", 607 + div { class: "notebook-settings-danger-info", 608 + h3 { "Delete Notebook" } 609 + p { "Permanently delete this notebook and all its entries. This action cannot be undone." } 610 + } 611 + Button { 612 + variant: ButtonVariant::Destructive, 613 + onclick: move |_| show_delete_confirm.set(true), 614 + disabled: notebook_uri.is_none(), 615 + "Delete Notebook" 616 + } 617 + } 618 + 619 + if show_delete_confirm() { 620 + div { class: "notebook-settings-confirm-overlay", 621 + div { class: "notebook-settings-confirm-dialog", 622 + h3 { "Are you sure?" } 623 + p { "This will permanently delete the notebook and all its entries." } 624 + div { class: "notebook-settings-confirm-actions", 625 + Button { 626 + variant: ButtonVariant::Destructive, 627 + onclick: handle_delete, 628 + disabled: deleting(), 629 + if deleting() { "Deleting..." } else { "Yes, Delete" } 630 + } 631 + Button { 632 + variant: ButtonVariant::Ghost, 633 + onclick: move |_| show_delete_confirm.set(false), 634 + disabled: deleting(), 635 + "Cancel" 636 + } 637 + } 638 + } 639 + } 640 + } 641 + } 642 + } 643 + } 644 + 645 + // --- Helper functions for full theme sync --- 646 + 647 + use jacquard::types::string::{Cid, RecordKey}; 648 + use weaver_api::sh_weaver::notebook::colour_scheme::ColourSchemeColours; 649 + use weaver_common::WeaverError; 650 + 651 + /// Result of syncing theme records. 652 + pub struct FullThemeSyncResult { 653 + pub theme_uri: AtUri<'static>, 654 + pub theme_cid: Cid<'static>, 655 + } 656 + 657 + /// Load full theme values from existing theme records. 658 + async fn load_full_theme_values( 659 + fetcher: &Fetcher, 660 + theme_ref: &StrongRef<'_>, 661 + ) -> Result<ThemeEditorValues, WeaverError> { 662 + // Fetch Theme record. 663 + let theme: Theme<'static> = fetcher 664 + .fetch_record( 665 + &Theme::uri(theme_ref.uri.as_ref()) 666 + .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?, 667 + ) 668 + .await 669 + .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))? 670 + .value 671 + .into_static(); 672 + 673 + // Fetch light ColourScheme. 674 + let light_scheme: ColourScheme<'static> = fetcher 675 + .fetch_record( 676 + &ColourScheme::uri(theme.light_scheme.uri.as_ref()) 677 + .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?, 678 + ) 679 + .await 680 + .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))? 681 + .value 682 + .into_static(); 683 + 684 + // Fetch dark ColourScheme. 685 + let dark_scheme: ColourScheme<'static> = fetcher 686 + .fetch_record( 687 + &ColourScheme::uri(theme.dark_scheme.uri.as_ref()) 688 + .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?, 689 + ) 690 + .await 691 + .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))? 692 + .value 693 + .into_static(); 694 + 695 + // Extract code themes. 696 + let light_code_theme = match &theme.light_code_theme { 697 + ThemeLightCodeTheme::CodeThemeName(name) => name.as_ref().to_string(), 698 + ThemeLightCodeTheme::CodeThemeFile(file) => file.name.as_ref().to_string(), 699 + ThemeLightCodeTheme::Unknown(_) => "base16-ocean.light".to_string(), 700 + }; 701 + let dark_code_theme = match &theme.dark_code_theme { 702 + ThemeDarkCodeTheme::CodeThemeName(name) => name.as_ref().to_string(), 703 + ThemeDarkCodeTheme::CodeThemeFile(file) => file.name.as_ref().to_string(), 704 + ThemeDarkCodeTheme::Unknown(_) => "base16-ocean.dark".to_string(), 705 + }; 706 + 707 + let default_mode = theme 708 + .default_theme 709 + .as_ref() 710 + .map(|s| s.to_string()) 711 + .unwrap_or_else(|| "auto".to_string()); 712 + 713 + fn colours_to_scheme(colours: &ColourSchemeColours<'_>) -> ColourSchemeValues { 714 + ColourSchemeValues { 715 + base: strip_hex(&colours.base), 716 + surface: strip_hex(&colours.surface), 717 + overlay: strip_hex(&colours.overlay), 718 + text: strip_hex(&colours.text), 719 + muted: strip_hex(&colours.muted), 720 + subtle: strip_hex(&colours.subtle), 721 + emphasis: strip_hex(&colours.emphasis), 722 + primary: strip_hex(&colours.primary), 723 + secondary: strip_hex(&colours.secondary), 724 + tertiary: strip_hex(&colours.tertiary), 725 + error: strip_hex(&colours.error), 726 + warning: strip_hex(&colours.warning), 727 + success: strip_hex(&colours.success), 728 + border: strip_hex(&colours.border), 729 + link: strip_hex(&colours.link), 730 + highlight: strip_hex(&colours.highlight), 731 + } 732 + } 733 + 734 + Ok(ThemeEditorValues { 735 + light: colours_to_scheme(&light_scheme.colours), 736 + dark: colours_to_scheme(&dark_scheme.colours), 737 + font_body: String::new(), 738 + font_heading: String::new(), 739 + font_mono: String::new(), 740 + spacing_base: theme.spacing.base_size.to_string(), 741 + spacing_line_height: theme.spacing.line_height.to_string(), 742 + spacing_scale: theme.spacing.scale.to_string(), 743 + light_code_theme, 744 + dark_code_theme, 745 + default_mode, 746 + }) 747 + } 748 + 749 + fn strip_hex(s: &str) -> String { 750 + s.trim_start_matches('#') 751 + .trim_start_matches("0x") 752 + .trim_start_matches("0X") 753 + .to_uppercase() 754 + } 755 + 756 + /// Sync full theme values to ColourScheme and Theme records. 757 + async fn sync_full_theme( 758 + fetcher: &Fetcher, 759 + existing_theme_ref: Option<&StrongRef<'_>>, 760 + values: &ThemeEditorValues, 761 + ) -> Result<FullThemeSyncResult, WeaverError> { 762 + fn scheme_to_colours(scheme: &ColourSchemeValues) -> ColourSchemeColours<'static> { 763 + ColourSchemeColours { 764 + base: format!("#{}", scheme.base).into(), 765 + surface: format!("#{}", scheme.surface).into(), 766 + overlay: format!("#{}", scheme.overlay).into(), 767 + text: format!("#{}", scheme.text).into(), 768 + muted: format!("#{}", scheme.muted).into(), 769 + subtle: format!("#{}", scheme.subtle).into(), 770 + emphasis: format!("#{}", scheme.emphasis).into(), 771 + primary: format!("#{}", scheme.primary).into(), 772 + secondary: format!("#{}", scheme.secondary).into(), 773 + tertiary: format!("#{}", scheme.tertiary).into(), 774 + error: format!("#{}", scheme.error).into(), 775 + warning: format!("#{}", scheme.warning).into(), 776 + success: format!("#{}", scheme.success).into(), 777 + border: format!("#{}", scheme.border).into(), 778 + link: format!("#{}", scheme.link).into(), 779 + highlight: format!("#{}", scheme.highlight).into(), 780 + extra_data: None, 781 + } 782 + } 783 + 784 + let light_colours = scheme_to_colours(&values.light); 785 + let dark_colours = scheme_to_colours(&values.dark); 786 + 787 + let light_code = 788 + ThemeLightCodeTheme::CodeThemeName(Box::new(CowStr::from(values.light_code_theme.clone()))); 789 + let dark_code = 790 + ThemeDarkCodeTheme::CodeThemeName(Box::new(CowStr::from(values.dark_code_theme.clone()))); 791 + let default_theme: Option<CowStr<'static>> = match values.default_mode.as_str() { 792 + "light" => Some(CowStr::from("light")), 793 + "dark" => Some(CowStr::from("dark")), 794 + _ => None, 795 + }; 796 + 797 + if let Some(theme_ref) = existing_theme_ref { 798 + // UPDATE existing records. 799 + let theme_uri = &theme_ref.uri; 800 + let existing_theme: Theme<'static> = fetcher 801 + .fetch_record( 802 + &Theme::uri(theme_uri.as_ref()) 803 + .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?, 804 + ) 805 + .await 806 + .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))? 807 + .value 808 + .into_static(); 809 + 810 + // Update light ColourScheme. 811 + let light_scheme_rkey = 812 + existing_theme.light_scheme.uri.rkey().ok_or_else(|| { 813 + WeaverError::InvalidNotebook("Light scheme URI missing rkey".into()) 814 + })?; 815 + let light_result = fetcher 816 + .put_record( 817 + RecordKey::any(light_scheme_rkey.as_ref()) 818 + .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))? 819 + .into_static(), 820 + ColourScheme::new() 821 + .name(CowStr::from("Custom Light")) 822 + .variant(CowStr::from("light")) 823 + .colours(light_colours) 824 + .build(), 825 + ) 826 + .await 827 + .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?; 828 + 829 + // Update dark ColourScheme. 830 + let dark_scheme_rkey = 831 + existing_theme.dark_scheme.uri.rkey().ok_or_else(|| { 832 + WeaverError::InvalidNotebook("Dark scheme URI missing rkey".into()) 833 + })?; 834 + let dark_result = fetcher 835 + .put_record( 836 + RecordKey::any(dark_scheme_rkey.as_ref()) 837 + .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))? 838 + .into_static(), 839 + ColourScheme::new() 840 + .name(CowStr::from("Custom Dark")) 841 + .variant(CowStr::from("dark")) 842 + .colours(dark_colours) 843 + .build(), 844 + ) 845 + .await 846 + .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?; 847 + 848 + // Update Theme with new CIDs. 849 + let theme_rkey = theme_uri 850 + .rkey() 851 + .ok_or_else(|| WeaverError::InvalidNotebook("Theme URI missing rkey".into()))?; 852 + let theme_result = fetcher 853 + .put_record( 854 + RecordKey::any(theme_rkey.as_ref()) 855 + .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))? 856 + .into_static(), 857 + Theme::new() 858 + .light_scheme( 859 + StrongRef::new() 860 + .uri(light_result.uri.clone().into_static()) 861 + .cid(light_result.cid.clone().into_static()) 862 + .build(), 863 + ) 864 + .dark_scheme( 865 + StrongRef::new() 866 + .uri(dark_result.uri.clone().into_static()) 867 + .cid(dark_result.cid.clone().into_static()) 868 + .build(), 869 + ) 870 + .light_code_theme(light_code) 871 + .dark_code_theme(dark_code) 872 + .fonts( 873 + ThemeFonts::new() 874 + .body(vec![]) 875 + .heading(vec![]) 876 + .monospace(vec![]) 877 + .build(), 878 + ) 879 + .spacing(ThemeSpacing { 880 + base_size: CowStr::from(values.spacing_base.clone()), 881 + line_height: CowStr::from(values.spacing_line_height.clone()), 882 + scale: CowStr::from(values.spacing_scale.clone()), 883 + extra_data: None, 884 + }) 885 + .maybe_default_theme(default_theme) 886 + .build(), 887 + ) 888 + .await 889 + .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?; 890 + 891 + Ok(FullThemeSyncResult { 892 + theme_uri: theme_result.uri.into_static(), 893 + theme_cid: theme_result.cid.into_static(), 894 + }) 895 + } else { 896 + // CREATE new records. 897 + let light_scheme = ColourScheme::new() 898 + .name(CowStr::from("Custom Light")) 899 + .variant(CowStr::from("light")) 900 + .colours(light_colours) 901 + .build(); 902 + 903 + let light_result = fetcher 904 + .create_record(light_scheme, None) 905 + .await 906 + .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?; 907 + 908 + let dark_scheme = ColourScheme::new() 909 + .name(CowStr::from("Custom Dark")) 910 + .variant(CowStr::from("dark")) 911 + .colours(dark_colours) 912 + .build(); 913 + 914 + let dark_result = fetcher 915 + .create_record(dark_scheme, None) 916 + .await 917 + .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?; 918 + 919 + let theme = Theme::new() 920 + .light_scheme( 921 + StrongRef::new() 922 + .uri(light_result.uri.clone().into_static()) 923 + .cid(light_result.cid.clone().into_static()) 924 + .build(), 925 + ) 926 + .dark_scheme( 927 + StrongRef::new() 928 + .uri(dark_result.uri.clone().into_static()) 929 + .cid(dark_result.cid.clone().into_static()) 930 + .build(), 931 + ) 932 + .light_code_theme(light_code) 933 + .dark_code_theme(dark_code) 934 + .fonts( 935 + ThemeFonts::new() 936 + .body(vec![]) 937 + .heading(vec![]) 938 + .monospace(vec![]) 939 + .build(), 940 + ) 941 + .spacing(ThemeSpacing { 942 + base_size: CowStr::from(values.spacing_base.clone()), 943 + line_height: CowStr::from(values.spacing_line_height.clone()), 944 + scale: CowStr::from(values.spacing_scale.clone()), 945 + extra_data: None, 946 + }) 947 + .maybe_default_theme(default_theme) 948 + .build(); 949 + 950 + let theme_result = fetcher 951 + .create_record(theme, None) 952 + .await 953 + .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?; 954 + 955 + Ok(FullThemeSyncResult { 956 + theme_uri: theme_result.uri.into_static(), 957 + theme_cid: theme_result.cid.into_static(), 958 + }) 959 + } 960 + }