| 1 | //! The design system reference: a component sheet and a rationale page. | |
| 2 | //! | |
| 3 | //! These are internal documentation that renders itself. A swatch sheet built | |
| 4 | //! from the same stylesheet as the product cannot drift from it — which is the | |
| 5 | //! whole reason to have one, and the reason it is a route rather than a | |
| 6 | //! screenshot in a wiki. | |
| 7 | ||
| 8 | use maud::{html, Markup}; | |
| 9 | ||
| 10 | use crate::views::change::{change_chip, state_badge}; | |
| 11 | ||
| 12 | /// Every colour token, as a swatch grid. | |
| 13 | const NEUTRALS: &[(&str, &str)] = &[ | |
| 14 | ("--bg", "page ground"), | |
| 15 | ("--surface", "panels, cards, bars"), | |
| 16 | ("--surface-raised", "controls on a surface"), | |
| 17 | ("--border", "hairlines"), | |
| 18 | ("--border-strong", "control edges"), | |
| 19 | ("--text", "body"), | |
| 20 | ("--text-dim", "secondary"), | |
| 21 | ("--text-faint", "tertiary, metadata"), | |
| 22 | ]; | |
| 23 | ||
| 24 | const ACCENTS: &[(&str, &str)] = &[ | |
| 25 | ("--identity", "change ids, stacks, revisions"), | |
| 26 | ("--action", "links, the one primary move"), | |
| 27 | ("--identity-wash", "stacked rows, selected revisions"), | |
| 28 | ]; | |
| 29 | ||
| 30 | const STATES: &[(&str, &str)] = &[ | |
| 31 | ("--open", "open"), | |
| 32 | ("--merged", "merged"), | |
| 33 | ("--abandoned", "abandoned"), | |
| 34 | ("--conflict", "conflicted"), | |
| 35 | ("--danger", "destructive or broken"), | |
| 36 | ]; | |
| 37 | ||
| 38 | fn swatches(title: &str, tokens: &[(&str, &str)]) -> Markup { | |
| 39 | html! { | |
| 40 | h3 .swatch-group-title { (title) } | |
| 41 | div .swatches { | |
| 42 | @for (token, use_for) in tokens { | |
| 43 | div .swatch { | |
| 44 | span .swatch-chip style=(format!("background:var({token})")) {} | |
| 45 | code .swatch-name { (token) } | |
| 46 | span .faint { (use_for) } | |
| 47 | } | |
| 48 | } | |
| 49 | } | |
| 50 | } | |
| 51 | } | |
| 52 | ||
| 53 | fn section(id: &str, title: &str, body: Markup) -> Markup { | |
| 54 | html! { | |
| 55 | section .design-section id=(id) aria-labelledby=(format!("{id}-h")) { | |
| 56 | h2 #(format!("{id}-h")) .design-section-title { (title) } | |
| 57 | (body) | |
| 58 | } | |
| 59 | } | |
| 60 | } | |
| 61 | ||
| 62 | /// `/design` — the component sheet. | |
| 63 | pub fn sheet() -> Markup { | |
| 64 | html! { | |
| 65 | div .design-head { | |
| 66 | h1 .display.design-title { "Component sheet" } | |
| 67 | p .dim { | |
| 68 | "Every token and control, rendered from the same stylesheet the product uses. " | |
| 69 | a href="/design/rationale" { "Read the rationale" } "." | |
| 70 | } | |
| 71 | } | |
| 72 | ||
| 73 | (section("tokens", "Colour tokens", html! { | |
| 74 | (swatches("Neutrals", NEUTRALS)) | |
| 75 | (swatches("Accents", ACCENTS)) | |
| 76 | (swatches("State", STATES)) | |
| 77 | p .hint { | |
| 78 | "Every token has a light-theme counterpart. Switching the theme re-renders this | |
| 79 | page from the same variables, so a swatch that looks wrong here is wrong in the | |
| 80 | product." | |
| 81 | } | |
| 82 | })) | |
| 83 | ||
| 84 | (section("type", "Typography", html! { | |
| 85 | div .type-specimens { | |
| 86 | @for (spec, sample, style) in [ | |
| 87 | ("44 / 48 · 600", "Display", "font-size:var(--text-3xl);line-height:48px;font-weight:600;letter-spacing:-0.02em"), | |
| 88 | ("24 / 32 · 600", "Page title", "font-size:var(--text-xl);line-height:32px;font-weight:600"), | |
| 89 | ("20 / 28 · 600", "Section title", "font-size:var(--text-lg);line-height:28px;font-weight:600"), | |
| 90 | ("16 / 24 · 400", "Lead paragraph", "font-size:var(--text-md);line-height:24px"), | |
| 91 | ("14 / 21 · 400", "Body and row titles", "font-size:var(--text-base);line-height:21px"), | |
| 92 | ("12.5 / 18 · 400", "Metadata, secondary", "font-size:var(--text-sm);line-height:18px;color:var(--text-dim)"), | |
| 93 | ("11 · mono", "kntqzsqtwrln +18 −4", "font-family:var(--font-mono);font-size:var(--text-xs);color:var(--text-faint)"), | |
| 94 | ("11 · condensed", "EYEBROW LABEL", "font-family:var(--font-condensed);font-size:var(--text-xs);font-weight:500;letter-spacing:0.06em;text-transform:uppercase;color:var(--text-dim)"), | |
| 95 | ] { | |
| 96 | div .type-row { | |
| 97 | span .type-spec { (spec) } | |
| 98 | span style=(style) { (sample) } | |
| 99 | } | |
| 100 | } | |
| 101 | } | |
| 102 | })) | |
| 103 | ||
| 104 | (section("states", "Change states", html! { | |
| 105 | div .state-list { | |
| 106 | @for (state, conflicted, meaning) in [ | |
| 107 | ("open", false, "pushed and reviewable"), | |
| 108 | ("open", true, "mid-thought, still reviewable"), | |
| 109 | ("merged", false, "landed on a bookmark"), | |
| 110 | ("abandoned", false, "closed without landing"), | |
| 111 | ("draft", false, "the author's own flag; never inferred from a push"), | |
| 112 | ] { | |
| 113 | div .state-row { | |
| 114 | (state_badge(state, conflicted)) | |
| 115 | span .dim { (meaning) } | |
| 116 | } | |
| 117 | } | |
| 118 | } | |
| 119 | p .hint.measure { | |
| 120 | "Conflicted is not a fifth state — it is something an open change can be. \ | |
| 121 | That is why it renders as one pill rather than two, and why it is magenta \ | |
| 122 | rather than red: a conflict is a state, not a failure." | |
| 123 | } | |
| 124 | })) | |
| 125 | ||
| 126 | (section("keys", "Keycaps", html! { | |
| 127 | div .design-row { | |
| 128 | @for k in ["⌘K", "j", "k", "↵", "y", "t", "esc", "?"] { | |
| 129 | span .kbd { (k) } | |
| 130 | } | |
| 131 | } | |
| 132 | p .hint.measure { | |
| 133 | "Only ⌘K is bound. The rest are drawn but not wired — a keycap that does \ | |
| 134 | nothing is a promise the product has not kept, so they appear here and \ | |
| 135 | nowhere else." | |
| 136 | } | |
| 137 | })) | |
| 138 | ||
| 139 | (section("diffrows", "Diff rows", html! { | |
| 140 | div .filelist style="max-width:720px" { | |
| 141 | @for (class, ln, text) in [ | |
| 142 | ("diff-hunk", "", "@@ hunk header — surface-raised, no rule"), | |
| 143 | ("line-add", "137", "+ addition — add-bg with a 2px add rule"), | |
| 144 | ("line-del", "136", "- deletion — del-bg with a 2px del rule"), | |
| 145 | ("line-ctx", "149", " context — transparent, body colour"), | |
| 146 | ] { | |
| 147 | div .diffline.(class) { | |
| 148 | span .diff-ln { (ln) } | |
| 149 | span .diff-text { (text) } | |
| 150 | } | |
| 151 | } | |
| 152 | } | |
| 153 | })) | |
| 154 | ||
| 155 | (section("stats", "Stat lines", html! { | |
| 156 | div style="max-width:240px" { | |
| 157 | @for (k, v, colour) in [ | |
| 158 | ("Open changes", "128", "var(--open)"), | |
| 159 | ("Conflicted", "3", "var(--conflict)"), | |
| 160 | ("Median time to first review", "42m", "var(--text-dim)"), | |
| 161 | ] { | |
| 162 | div .dotline { | |
| 163 | span .dotline-key { (k) } | |
| 164 | span .dotline-val style=(format!("color:{colour}")) { (v) } | |
| 165 | } | |
| 166 | } | |
| 167 | } | |
| 168 | p .hint.measure { | |
| 169 | "The leader is a flexing border rather than a run of periods, so both ends \ | |
| 170 | sit on the same baseline whatever their lengths are." | |
| 171 | } | |
| 172 | })) | |
| 173 | ||
| 174 | (section("buttons", "Buttons", html! { | |
| 175 | div .design-row { | |
| 176 | a .btn.btn-primary href="#buttons" { "Primary" } | |
| 177 | a .btn href="#buttons" { "Default" } | |
| 178 | a .btn.btn-danger href="#buttons" { "Danger" } | |
| 179 | span .btn.btn-quiet-danger { "Quiet danger" } | |
| 180 | span .btn.is-disabled aria-disabled="true" { "Disabled" } | |
| 181 | } | |
| 182 | div .design-row { | |
| 183 | a .btn.btn-primary.btn-lg href="#buttons" { "Large primary" } | |
| 184 | a .btn.btn-lg href="#buttons" { "Large default" } | |
| 185 | } | |
| 186 | p .hint.measure { | |
| 187 | "Primary is filled `--action` and there is at most one on a screen. A second | |
| 188 | primary button means the page has not decided what it is for. Everything | |
| 189 | else is the transparent default, which reaches for the accent only on hover." | |
| 190 | } | |
| 191 | })) | |
| 192 | ||
| 193 | (section("segmented", "Segmented control", html! { | |
| 194 | div .design-row { | |
| 195 | div .segmented role="group" aria-label="Example" { | |
| 196 | a .segmented-item.is-on href="#segmented" { "rendered" } | |
| 197 | a .segmented-item href="#segmented" { "source" } | |
| 198 | } | |
| 199 | } | |
| 200 | p .hint { | |
| 201 | "Two links, not a scripted toggle — each half is a real URL, so the control | |
| 202 | works without JavaScript and either view can be linked to." | |
| 203 | } | |
| 204 | })) | |
| 205 | ||
| 206 | (section("chips", "Chips and badges", html! { | |
| 207 | div .design-row { | |
| 208 | (change_chip("kmnwolvtqrsz", false)) | |
| 209 | (change_chip("anything", true)) | |
| 210 | span .chip { "private" } | |
| 211 | span .chip { "draft" } | |
| 212 | } | |
| 213 | div .design-row { | |
| 214 | (state_badge("open", false)) | |
| 215 | (state_badge("merged", false)) | |
| 216 | (state_badge("abandoned", false)) | |
| 217 | (state_badge("open", true)) | |
| 218 | } | |
| 219 | p .hint { | |
| 220 | "A state is never colour alone — every badge carries its own name as text, so | |
| 221 | it survives a monochrome display and a colour-blind reader." | |
| 222 | } | |
| 223 | })) | |
| 224 | ||
| 225 | (section("forms", "Form controls", html! { | |
| 226 | div .field { | |
| 227 | label for="d-text" { "Text input" } | |
| 228 | input type="text" id="d-text" value="dogfood"; | |
| 229 | p .hint { "A hint sits under the control it explains." } | |
| 230 | } | |
| 231 | div .field { | |
| 232 | label for="d-area" { "Textarea" } | |
| 233 | textarea id="d-area" rows="3" { "Monospace, because it usually holds code." } | |
| 234 | } | |
| 235 | div .field { | |
| 236 | label for="d-select" { "Select" } | |
| 237 | select id="d-select" { | |
| 238 | option { "main" } | |
| 239 | option { "release" } | |
| 240 | } | |
| 241 | } | |
| 242 | })) | |
| 243 | ||
| 244 | (section("banners", "Banners", html! { | |
| 245 | div .banner role="status" { "A neutral banner." } | |
| 246 | div .banner.banner-ok role="status" { "Something worked." } | |
| 247 | div .banner.banner-error role="alert" { "Something failed, and here is why." } | |
| 248 | })) | |
| 249 | ||
| 250 | (section("empty", "Empty state", html! { | |
| 251 | div .empty { | |
| 252 | h2 { "No changes yet" } | |
| 253 | p { "Push one with " code { "jj git push" } "." } | |
| 254 | p { a .btn.btn-primary href="#empty" { "New repository" } } | |
| 255 | } | |
| 256 | })) | |
| 257 | ||
| 258 | (section("compare", "Comparison list", html! { | |
| 259 | ul .compare { | |
| 260 | li .compare-item { | |
| 261 | span .compare-them { | |
| 262 | span .compare-sign aria-hidden="true" { "−" } | |
| 263 | "the old way" | |
| 264 | } | |
| 265 | span .compare-us { | |
| 266 | span .compare-sign aria-hidden="true" { "+" } | |
| 267 | "the new way" | |
| 268 | } | |
| 269 | } | |
| 270 | } | |
| 271 | })) | |
| 272 | } | |
| 273 | } | |
| 274 | ||
| 275 | /// `/design/rationale` — why the system is the way it is. | |
| 276 | /// | |
| 277 | /// Written against the current tokens. The prototype's own rationale page still | |
| 278 | /// describes the pre-brand palette, so this is not a port of that text. | |
| 279 | pub fn rationale() -> Markup { | |
| 280 | html! { | |
| 281 | article .rationale { | |
| 282 | header { | |
| 283 | h1 .display.design-title { "Design rationale" } | |
| 284 | p .dim { | |
| 285 | "One page, for the cases the component sheet does not cover. " | |
| 286 | a href="/design" { "Back to the component sheet" } "." | |
| 287 | } | |
| 288 | } | |
| 289 | ||
| 290 | section { | |
| 291 | h2 { "Two accents, two jobs" } | |
| 292 | p .dim { | |
| 293 | "Gold is " em { "identity" } " — change ids, stack rails, the revision | |
| 294 | timeline, the wash behind a stacked row. Every place the interface is | |
| 295 | pointing at a thing that keeps its name through a rewrite. Teal is " | |
| 296 | em { "action" } " — links, and the one primary control on a page. The test | |
| 297 | when adding an element: does it " em { "name" } " a change, or does it " | |
| 298 | em { "do" } " something? It is never both. A gold button or a teal change | |
| 299 | id breaks the only colour rule the interface has, and once either starts | |
| 300 | leaking the other stops meaning anything." | |
| 301 | } | |
| 302 | p .dim { | |
| 303 | "There is deliberately no third brand colour. An earlier revision of this | |
| 304 | system had one — a vermilion reserved for the wordmark and marketing | |
| 305 | surfaces — and it spent its force competing with the two accents that | |
| 306 | carry meaning. Identity and action are the whole palette; everything else | |
| 307 | is neutral or a state." | |
| 308 | } | |
| 309 | } | |
| 310 | ||
| 311 | section { | |
| 312 | h2 { "Conflict is magenta, not red" } | |
| 313 | p .dim { | |
| 314 | "In jj a conflict is a committed, reviewable state — not an error. Colouring | |
| 315 | it red would teach the wrong model on the first encounter and take a long | |
| 316 | time to unteach. Red is reserved for destructive actions and for things that | |
| 317 | are actually broken." | |
| 318 | } | |
| 319 | } | |
| 320 | ||
| 321 | section { | |
| 322 | h2 { "The type pairing" } | |
| 323 | p .dim { | |
| 324 | "Mono carries the content: identifiers, paths, diffs, timestamps, numbers. | |
| 325 | IBM Plex Sans carries the interface around it. Plex Sans Condensed does two | |
| 326 | jobs — uppercase at 11px for labels and column headers, where it buys | |
| 327 | horizontal room in dense tables and subordinates the label to its value; and | |
| 328 | large and bold for display headings, where it gives the marketing surfaces a | |
| 329 | voice the product surfaces do not have. Base is 14px on 21px. If a new | |
| 330 | element holds data, set it in mono; if it explains data, sans; if it names a | |
| 331 | column of data, condense it." | |
| 332 | } | |
| 333 | p .dim { | |
| 334 | "All three faces are self-hosted Latin subsets. A forge should not tell a | |
| 335 | third party which repository you are reading, which is what a font CDN | |
| 336 | learns on every page load." | |
| 337 | } | |
| 338 | } | |
| 339 | ||
| 340 | section { | |
| 341 | h2 { "Density over decoration" } | |
| 342 | p .dim { | |
| 343 | "Radius is 3px, borders are one hairline, and shadows appear only on things | |
| 344 | that genuinely float. The pages that matter — change lists, diffs, file | |
| 345 | trees — are read for hours, and every pixel of chrome is one not spent on | |
| 346 | code. Rows are 28 to 34px, tables are separated by hairlines rather than | |
| 347 | boxed, and the only decorative element in the whole system is the tagline | |
| 348 | strip on the landing page." | |
| 349 | } | |
| 350 | p .dim { | |
| 351 | "Nothing animates. The design this was ported from types into a terminal | |
| 352 | and scrolls a marquee; neither ships. A console that types at you is | |
| 353 | indistinguishable from one showing live output, and a permanently moving | |
| 354 | strip is a permanently moving object on the page for a reader who did not | |
| 355 | ask for one." | |
| 356 | } | |
| 357 | } | |
| 358 | ||
| 359 | section { | |
| 360 | h2 { "Nothing depends on JavaScript" } | |
| 361 | p .dim { | |
| 362 | "Progressive enhancement is a hard requirement, so every control here has a | |
| 363 | no-script form: toggles are links to real URLs, filters are GET forms, and | |
| 364 | the line numbers in a blob are anchors. htmx is loaded for enhancement only. | |
| 365 | A control that cannot be built this way is a control this system does not | |
| 366 | have." | |
| 367 | } | |
| 368 | } | |
| 369 | } | |
| 370 | } | |
| 371 | } |
371 lines · Rust