michal/tit

Diff

a76e41a09078ee64ad60d39b

README.md

Mode 100644100644; object e9c9248eb7d4969ff20eac8e

@@ -292,3 +292,5 @@
 ```
 
 `tit` is licensed under the MIT License. See [LICENSE](LICENSE).
+Space Mono is licensed under the SIL Open Font License, Version 1.1. See
+[assets/SpaceMono-LICENSE.txt](assets/SpaceMono-LICENSE.txt).

assets/SpaceMono-Bold.ttf

Mode 100644; object 7c38f236f12f

Binary content changed.

assets/SpaceMono-LICENSE.txt

Mode 100644; object 389d65c1c36d

@@ -1,0 +1,93 @@
+Copyright 2016 The Space Mono Project Authors (https://github.com/googlefonts/spacemono)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.

assets/SpaceMono-Regular.ttf

Mode 100644; object 8bf82cece1da

Binary content changed.

assets/style.css

Mode 100644100644; object f66bd67e6025751412b5213b

@@ -1,40 +1,240 @@
+@font-face {
+  font-family: "Space Mono";
+  font-style: normal;
+  font-weight: 400;
+  font-display: swap;
+  src: url("/assets/SpaceMono-Regular.ttf") format("truetype");
+}
+
+@font-face {
+  font-family: "Space Mono";
+  font-style: normal;
+  font-weight: 700;
+  font-display: swap;
+  src: url("/assets/SpaceMono-Bold.ttf") format("truetype");
+}
+
 :root {
-  color-scheme: light dark;
-  font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
-  line-height: 1.5;
-  background: Canvas;
-  color: CanvasText;
+  color-scheme: dark;
+  --bg: #000;
+  --surface: #080808;
+  --surface-raised: #101010;
+  --panel: rgb(0 0 0 / 88%);
+  --fg: #e5e5e5;
+  --fg-dim: #999;
+  --fg-muted: #666;
+  --border: #333;
+  --border-strong: #777;
+  --error: #ffb4ab;
+  font-family: "Space Mono", ui-monospace, "SFMono-Regular", Consolas, monospace;
+  font-size: 16px;
+  line-height: 1.6;
+  background: var(--bg);
+  color: var(--fg);
 }
 
 * {
   box-sizing: border-box;
 }
 
+html {
+  min-width: 20rem;
+  background: var(--bg);
+}
+
 body {
-  width: min(100% - 2rem, 64rem);
-  margin: 0 auto;
+  min-height: 100vh;
+  margin: 0;
+  padding: 2rem;
+  background: var(--bg);
+  color: var(--fg);
+  display: flex;
+  flex-direction: column;
+}
+
+a {
+  color: var(--fg);
+  text-decoration: none;
+}
+
+a:hover {
+  text-decoration: underline;
+  text-underline-offset: 0.18em;
+}
+
+:focus-visible {
+  outline: 0.125rem solid var(--fg);
+  outline-offset: 0.25rem;
+}
+
+code,
+pre,
+input,
+textarea,
+select,
+button {
+  font: inherit;
+}
+
+code {
+  color: var(--fg);
+  font-size: 0.875em;
+  overflow-wrap: anywhere;
+}
+
+h1,
+h2,
+h3 {
+  line-height: 1.25;
+}
+
+h1 {
+  margin-block: 0 1rem;
+  font-size: clamp(2rem, 5vw, 3.5rem);
+  letter-spacing: -0.04em;
+}
+
+h2 {
+  margin-block: 0 1rem;
+  font-size: 1rem;
+}
+
+h3 {
+  margin-block: 1.5rem 0.5rem;
+  font-size: 0.875rem;
+}
+
+p {
+  max-width: 72ch;
 }
 
 .site-header {
   display: flex;
   flex-wrap: wrap;
-  align-items: baseline;
+  align-items: center;
   justify-content: space-between;
-  gap: 1rem;
-  padding: 1rem 0;
-  border-bottom: 1px solid GrayText;
+  gap: 1rem 2rem;
+  margin-bottom: 1rem;
+}
+
+.site-header-rule {
+  width: 100%;
+  margin: 0 0 4rem;
+  border: 0;
+  border-top: 1px solid var(--border);
 }
 
 .brand {
-  color: inherit;
-  font-size: 1.5rem;
+  display: flex;
+  align-items: center;
+  gap: 0.6rem;
+  color: var(--fg);
+  font-size: 1.25rem;
   font-weight: 700;
   text-decoration: none;
 }
 
+.brand-icon {
+  width: 1.5rem;
+  height: 1.5rem;
+  fill: none;
+  stroke: currentColor;
+  stroke-width: 2;
+  stroke-linecap: round;
+  stroke-linejoin: round;
+}
+
+.site-header nav,
+.repository-header nav,
+.pagination {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.5rem 1rem;
+}
+
+.site-header nav {
+  align-items: center;
+  justify-content: flex-end;
+  font-size: 0.75rem;
+}
+
+.site-header .icon-link {
+  display: grid;
+  width: 2.25rem;
+  height: 2.25rem;
+  border: 1px solid transparent;
+  color: var(--fg-dim);
+  place-items: center;
+}
+
+.icon-link {
+  position: relative;
+}
+
+.icon-link::after {
+  position: absolute;
+  top: calc(100% + 0.45rem);
+  right: 0;
+  z-index: 4;
+  width: max-content;
+  max-width: 12rem;
+  padding: 0.25rem 0.4rem;
+  border: 1px solid var(--border);
+  background: var(--bg);
+  color: var(--fg);
+  content: attr(aria-label);
+  font-size: 0.65rem;
+  opacity: 0;
+  pointer-events: none;
+  transform: translateY(-0.2rem);
+  transition:
+    opacity 120ms ease,
+    transform 120ms ease;
+}
+
+.icon-link:hover::after,
+.icon-link:focus-visible::after {
+  opacity: 1;
+  transform: translateY(0);
+}
+
+.site-header .icon-link:hover {
+  border-color: var(--border);
+  color: var(--fg);
+  text-decoration: none;
+}
+
+.icon-link svg,
+.account-options svg {
+  width: 1.25rem;
+  height: 1.25rem;
+  fill: none;
+  stroke: currentColor;
+  stroke-width: 2;
+  stroke-linecap: round;
+  stroke-linejoin: round;
+}
+
 main {
-  min-height: 65vh;
-  padding: 2rem 0;
+  width: 100%;
+  max-width: 68.75rem;
+  flex: 1;
+}
+
+footer {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: space-between;
+  gap: 0.5rem 2rem;
+  margin-top: 4rem;
+  padding: 1rem 0 0;
+  border-top: 1px solid var(--border);
+  color: var(--fg-muted);
+  font-size: 0.75rem;
+}
+
+footer code {
+  color: var(--fg-dim);
 }
 
 main > article,
@@ -42,20 +242,21 @@
 main > dl,
 main > form,
 main > ol,
-main > section:not(.repository-panel),
+main > section:not(.repository-panel):not(.home-workflow),
 main > ul,
 main > .field,
 main > .table-scroll,
-.two-column > section {
+.two-column > section,
+.auth-panel {
   margin-block: 1.5rem;
-  padding: 1rem;
-  border: 1px solid GrayText;
-  border-radius: 0.25rem;
+  padding: 1.25rem;
+  border: 1px solid var(--border);
+  background: var(--panel);
 }
 
 main > ol,
 main > ul {
-  padding-inline-start: 2.75rem;
+  padding-inline-start: 3rem;
 }
 
 main > article > :first-child,
@@ -63,10 +264,11 @@
 main > dl > :first-child,
 main > form > :first-child,
 main > ol > :first-child,
-main > section:not(.repository-panel) > :first-child,
+main > section > :first-child,
 main > ul > :first-child,
 main > .field > :first-child,
-.two-column > section > :first-child {
+.two-column > section > :first-child,
+.auth-panel > :first-child {
   margin-top: 0;
 }
 
@@ -75,75 +277,268 @@
 main > dl > :last-child,
 main > form > :last-child,
 main > ol > :last-child,
-main > section:not(.repository-panel) > :last-child,
+main > section > :last-child,
 main > ul > :last-child,
 main > .field > :last-child,
-.two-column > section > :last-child {
+.two-column > section > :last-child,
+.auth-panel > :last-child {
   margin-bottom: 0;
 }
 
-main > .table-scroll {
+.two-column {
+  display: grid;
+  grid-template-columns: minmax(0, 1.2fr) minmax(18rem, 0.8fr);
+  gap: 3rem;
+  align-items: start;
+}
+
+.home-intro {
+  min-height: 20rem;
+  align-self: stretch;
+}
+
+.home-grid > section {
+  display: flex;
+  height: 23rem;
+  flex-direction: column;
+  overflow: hidden;
+}
+
+.home-grid .home-repository-list {
+  min-height: 0;
+  overflow-y: auto;
+}
+
+.home-intro h1 {
+  max-width: 12ch;
+}
+
+.home-intro > p {
+  color: var(--fg-dim);
+}
+
+.pagination a {
+  display: inline-block;
+  padding: 0.55rem 0.75rem;
+  border: 1px solid var(--border-strong);
+  font-size: 0.75rem;
+}
+
+.pagination a:hover {
+  border-color: var(--fg);
+  text-decoration: none;
+}
+
+.home-repository-list,
+.issue-list,
+.repository-list,
+.activity-list,
+.key-list {
+  margin: 0;
   padding: 0;
+  list-style: none;
 }
 
-main > .table-scroll th,
-main > .table-scroll td {
-  padding: 0.6rem 1rem;
+.home-repository-list li,
+.issue-list li,
+.repository-list li,
+.activity-list li {
+  padding-block: 0.9rem;
+  border-bottom: 1px solid var(--border);
 }
 
-main > .table-scroll tbody tr:last-child > * {
+.home-repository-list li:last-child,
+.issue-list li:last-child,
+.repository-list li:last-child,
+.activity-list li:last-child {
   border-bottom: 0;
 }
 
-footer {
-  padding: 1rem 0;
-  border-top: 1px solid GrayText;
-}
-
-form {
-  max-width: 32rem;
-}
-
-details {
-  margin-block: 1.5rem;
-}
-
-summary {
-  cursor: pointer;
+.repository-name,
+.issue-title {
   font-weight: 700;
 }
 
+.repository-meta,
+.issue-meta {
+  display: block;
+  margin-top: 0.2rem;
+  color: var(--fg-muted);
+  font-size: 0.7rem;
+}
+
+.home-repository-list p {
+  margin: 0.35rem 0 0;
+  color: var(--fg-dim);
+  font-size: 0.875rem;
+}
+
+.home-repository-list .inline-form {
+  margin-top: 0.75rem;
+}
+
+.home-workflow {
+  margin-top: 3rem;
+}
+
+.home-workflow > h2 {
+  color: var(--fg-muted);
+  font-size: 0.75rem;
+  letter-spacing: 0.1em;
+  text-transform: uppercase;
+}
+
+.home-workflow ol {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 0.75rem;
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+
+.home-workflow li {
+  min-height: 11rem;
+  padding: 1rem;
+  border: 1px solid var(--border);
+  background: var(--panel);
+}
+
+.home-workflow li::before {
+  display: block;
+  margin-bottom: 2.5rem;
+  color: var(--fg-muted);
+  font-size: 0.7rem;
+  content: "0" counter(list-item);
+}
+
+.home-workflow strong {
+  display: block;
+  margin-bottom: 0.5rem;
+}
+
+.home-workflow p {
+  margin: 0;
+  color: var(--fg-dim);
+  font-size: 0.875rem;
+}
+
 .repository-header {
-  display: flex;
-  flex-wrap: wrap;
-  align-items: baseline;
-  justify-content: space-between;
-  gap: 0.5rem 2rem;
+  margin-bottom: 2rem;
+  padding-bottom: 1rem;
+  border-bottom: 1px solid var(--border);
 }
 
 .repository-header h1 {
-  margin-block: 0.5rem;
+  margin-block: 0 1.25rem;
+  font-size: clamp(1.75rem, 4vw, 2.75rem);
+  overflow-wrap: anywhere;
 }
 
-.repository-header nav {
-  justify-content: flex-end;
-  font-size: 0.875rem;
+.repository-navigation {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: stretch;
+  justify-content: space-between;
+  gap: 0.75rem 2rem;
+}
+
+.repository-tabs,
+.repository-tools,
+.repository-context,
+.repository-actions {
+  display: flex;
+  align-items: center;
+}
+
+.repository-tabs {
+  flex-wrap: wrap;
+}
+
+.repository-tabs a {
+  padding: 0.45rem 0.65rem;
+  border: 1px solid var(--border);
+  border-right: 0;
+  color: var(--fg-dim);
+  font-size: 0.7rem;
+}
+
+.repository-tabs a:last-child {
+  border-right: 1px solid var(--border);
+}
+
+.repository-tabs a:hover {
+  border-color: var(--border-strong);
+  color: var(--fg);
+  text-decoration: none;
+}
+
+.repository-tabs a[aria-current="page"] {
+  border-color: var(--fg);
+  background: var(--fg);
+  color: var(--bg);
+  font-weight: 700;
+}
+
+.repository-tools {
+  gap: 0.35rem;
+  margin-left: auto;
+}
+
+.repository-tools .icon-link,
+.repository-actions .icon-link,
+.page-section-header .icon-link {
+  display: grid;
+  width: 2.25rem;
+  height: 2.25rem;
+  border: 1px solid var(--border);
+  color: var(--fg-dim);
+  place-items: center;
+}
+
+.repository-tools .icon-link:hover,
+.repository-actions .icon-link:hover,
+.page-section-header .icon-link:hover {
+  border-color: var(--border-strong);
+  color: var(--fg);
+  text-decoration: none;
+}
+
+.repository-tools .icon-link[aria-current="page"] {
+  border-color: var(--fg);
+  color: var(--fg);
 }
 
 .repository-toolbar {
   display: flex;
   flex-wrap: wrap;
   align-items: center;
-  gap: 0.5rem 1rem;
+  justify-content: space-between;
+  gap: 0.75rem 2rem;
   margin-block: 1.5rem;
   padding: 0.75rem 1rem;
-  border: 1px solid GrayText;
-  border-radius: 0.25rem;
+  border: 1px solid var(--border);
+  background: var(--surface);
+  font-size: 0.75rem;
+}
+
+.repository-toolbar strong {
+  color: var(--fg);
+}
+
+.repository-context {
+  flex-wrap: wrap;
+  gap: 0.5rem 1.5rem;
+}
+
+.repository-actions {
+  gap: 0.35rem;
+  margin-left: auto;
 }
 
 .repository-summary-layout {
   display: grid;
-  grid-template-columns: minmax(0, 1.7fr) minmax(23rem, 1fr);
+  grid-template-columns: minmax(0, 1.7fr) minmax(18rem, 0.8fr);
   gap: 2rem;
   align-items: start;
 }
@@ -155,17 +550,21 @@
 
 .repository-summary-main > section,
 .repository-sidebar > section {
-  margin-bottom: 2rem;
+  margin-bottom: 1.5rem;
 }
 
 .repository-panel {
   padding: 1rem;
-  border: 1px solid GrayText;
-  border-radius: 0.25rem;
+  border: 1px solid var(--border);
+  background: var(--panel);
 }
 
 .repository-panel > h2:first-child {
   margin-top: 0;
+  color: var(--fg-muted);
+  font-size: 0.75rem;
+  letter-spacing: 0.1em;
+  text-transform: uppercase;
 }
 
 .repository-file-panel {
@@ -175,7 +574,7 @@
 
 .repository-file-panel th,
 .repository-file-panel td {
-  padding: 0.6rem 1rem;
+  padding: 0.65rem 0.85rem;
 }
 
 .repository-file-panel tbody tr:last-child > * {
@@ -185,21 +584,37 @@
 .repository-file-more {
   margin: 0;
   padding: 0.75rem 1rem;
-  border-top: 1px solid GrayText;
+  border-top: 1px solid var(--border);
 }
 
 .repository-file-list th:first-child {
   width: 100%;
 }
 
-.repository-sidebar dd {
-  overflow-x: auto;
-  overflow-wrap: normal;
+.repository-file-list code,
+.repository-toolbar code {
   white-space: nowrap;
+  overflow-wrap: normal;
+}
+
+.repository-sidebar {
+  font-size: 0.8rem;
+}
+
+.repository-sidebar dd {
+  overflow-wrap: anywhere;
+}
+
+.repository-sidebar dd code {
+  display: block;
+  max-width: 100%;
+  overflow-x: auto;
+  white-space: nowrap;
+  overflow-wrap: normal;
 }
 
 .repository-recent-list {
-  padding-left: 1.5rem;
+  padding-left: 1.25rem;
 }
 
 .repository-recent-list li {
@@ -207,20 +622,122 @@
 }
 
 .repository-readme {
-  margin-block: 2rem;
+  margin-top: 1.5rem;
 }
 
-.site-header nav,
-.repository-header nav {
+.markdown {
+  max-width: 78ch;
+}
+
+form {
+  max-width: 36rem;
+}
+
+.field {
+  margin-block: 1rem;
+}
+
+label {
+  display: block;
+  margin-bottom: 0.35rem;
+  color: var(--fg-dim);
+  font-size: 0.75rem;
+  font-weight: 700;
+}
+
+input,
+textarea,
+select,
+button {
+  max-width: 100%;
+  padding: 0.65rem 0.75rem;
+  border: 1px solid var(--border-strong);
+  border-radius: 0;
+  background: var(--surface-raised);
+  color: var(--fg);
+}
+
+input {
+  width: 100%;
+}
+
+input[type="radio"],
+input[type="checkbox"] {
+  width: auto;
+  accent-color: var(--fg);
+}
+
+textarea {
+  width: 100%;
+  resize: vertical;
+}
+
+button {
+  background: var(--fg);
+  color: var(--bg);
+  cursor: pointer;
+  font-weight: 700;
+}
+
+button:hover {
+  background: var(--fg-dim);
+}
+
+.page-actions {
   display: flex;
   flex-wrap: wrap;
-  gap: 0.5rem 1rem;
+  gap: 0.75rem;
+  margin-top: 1.5rem;
+}
+
+.primary-action {
+  display: inline-block;
+  padding: 0.65rem 0.75rem;
+  border: 1px solid var(--fg);
+  background: var(--fg);
+  color: var(--bg);
+  font-weight: 700;
+}
+
+.primary-action:hover {
+  background: var(--fg-dim);
+  color: var(--bg);
+  text-decoration: none;
+}
+
+.danger-action {
+  border-color: var(--error);
+  background: var(--panel);
+  color: var(--error);
+}
+
+.danger-action:hover {
+  background: var(--error);
+  color: var(--bg);
+}
+
+.danger-link {
+  color: var(--error);
+}
+
+details {
+  margin-block: 1.5rem;
+}
+
+summary {
+  cursor: pointer;
+  font-weight: 700;
 }
 
 dl {
   display: grid;
   grid-template-columns: max-content minmax(0, 1fr);
-  gap: 0.25rem 1rem;
+  gap: 0.35rem 1rem;
+}
+
+dt {
+  color: var(--fg-muted);
+  font-size: 0.75rem;
 }
 
 dd {
@@ -231,6 +748,7 @@
 table {
   width: 100%;
   border-collapse: collapse;
+  font-size: 0.8rem;
 }
 
 .table-scroll {
@@ -238,19 +756,32 @@
   overflow-x: auto;
 }
 
+main > .table-scroll {
+  padding: 0;
+}
+
 th,
 td {
-  padding: 0.4rem;
-  border-bottom: 1px solid GrayText;
+  padding: 0.55rem;
+  border-bottom: 1px solid var(--border);
   text-align: left;
   vertical-align: top;
+}
+
+thead th {
+  color: var(--fg-muted);
+  font-size: 0.7rem;
+  text-transform: lowercase;
 }
 
 pre {
   max-width: 100%;
   padding: 1rem;
   overflow: auto;
-  border: 0.125rem solid GrayText;
+  border: 1px solid var(--border);
+  background: var(--surface);
+  color: var(--fg);
+  font-size: 0.8rem;
   white-space: pre-wrap;
   overflow-wrap: anywhere;
 }
@@ -260,197 +791,436 @@
   overflow-wrap: anywhere;
 }
 
-.field {
-  margin-block: 1rem;
-}
-
-label {
-  display: block;
-  margin-bottom: 0.25rem;
-  font-weight: 700;
-}
-
-input,
-textarea,
-select,
-button {
-  max-width: 100%;
-  padding: 0.6rem 0.75rem;
-  border: 1px solid GrayText;
-  border-radius: 0.25rem;
-  background: Canvas;
-  color: CanvasText;
-  font: inherit;
-}
-
-input {
-  width: 100%;
-}
-
-input[type="radio"],
-input[type="checkbox"] {
-  width: auto;
-}
-
-textarea {
-  width: 100%;
-  resize: vertical;
-}
-
-button {
-  cursor: pointer;
-  font-weight: 700;
-}
-
-a {
-  color: LinkText;
-}
-
-:focus-visible {
-  outline: 0.2rem solid Highlight;
-  outline-offset: 0.2rem;
-}
-
 .error {
   padding: 0.75rem;
-  border-left: 0.25rem solid #b42318;
+  border: 1px solid var(--error);
+  color: var(--error);
 }
 
-.comment-card {
-  margin-block: 1rem;
-  padding: 1rem;
-  border: 1px solid GrayText;
-  border-radius: 0.25rem;
-}
-
-.comment-card > h3 {
-  margin-top: 0;
-  padding-bottom: 0.5rem;
-  border-bottom: 1px solid GrayText;
-}
-
-.key-list {
-  padding: 0;
-  list-style: none;
-}
-
+.comment-card,
 .key-card {
   margin-block: 1rem;
   padding: 1rem;
-  border: 1px solid GrayText;
-  border-radius: 0.25rem;
+  border: 1px solid var(--border);
+  background: var(--panel);
 }
 
+.comment-card > h3,
 .key-card > h3 {
+  margin-top: 0;
+  padding-bottom: 0.5rem;
+  border-bottom: 1px solid var(--border);
+}
+
+.pull-request-page {
+  display: grid;
+  gap: 1.5rem;
+  margin-block: 0;
+  padding: 0;
+  border: 0;
+  background: transparent;
+}
+
+.pull-request-hero,
+.pull-request-section,
+.pull-request-history {
+  padding: 1.25rem;
+  border: 1px solid var(--border);
+  background: var(--panel);
+}
+
+.pull-request-title,
+.pull-request-section-heading {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 1rem 2rem;
+}
+
+.pull-request-title h2 {
+  max-width: 24ch;
+  margin: 0;
+  font-size: clamp(1.6rem, 4vw, 2.6rem);
+  overflow-wrap: anywhere;
+}
+
+.pull-request-kicker {
+  margin: 0 0 0.35rem;
+  color: var(--fg-muted);
+  font-size: 0.7rem;
+  font-weight: 700;
+  letter-spacing: 0.1em;
+  text-transform: uppercase;
+}
+
+.pull-request-state,
+.pull-request-mergeability {
+  flex: 0 0 auto;
+  padding: 0.3rem 0.55rem;
+  border: 1px solid var(--border-strong);
+  color: var(--fg);
+  font-size: 0.7rem;
+  font-weight: 700;
+  text-transform: lowercase;
+}
+
+.pull-request-byline {
+  margin: 0.75rem 0 1.5rem;
+  color: var(--fg-dim);
+  font-size: 0.75rem;
+}
+
+.pull-request-description {
+  padding-block: 1rem;
+  border-block: 1px solid var(--border);
+}
+
+.pull-request-description > :first-child {
   margin-top: 0;
 }
 
-.two-column {
-  display: grid;
-  grid-template-columns: minmax(0, 1fr) minmax(0, 1.6fr);
-  gap: 2rem;
-  align-items: start;
+.pull-request-description > :last-child {
+  margin-bottom: 0;
 }
 
-.profile-bio {
+.pull-request-branches {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
+  gap: 0.75rem;
+  align-items: center;
+  margin-top: 1rem;
+}
+
+.pull-request-branches > div {
+  min-width: 0;
+  padding: 0.75rem;
+  border: 1px solid var(--border);
+  background: var(--surface);
+}
+
+.pull-request-branches span,
+.pull-request-branches small {
+  display: block;
+  color: var(--fg-muted);
+  font-size: 0.65rem;
+}
+
+.pull-request-branches code {
+  display: block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.pull-request-direction {
+  color: var(--fg-dim);
+  font-size: 1.25rem;
+}
+
+.pull-request-actions {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: flex-start;
+  gap: 0.75rem;
+  margin-top: 1rem;
+}
+
+.pull-request-actions > details {
+  flex: 1 1 24rem;
+  margin: 0;
+}
+
+.pull-request-actions > form {
+  margin-left: auto;
+}
+
+.pull-request-actions details[open] {
+  padding: 0.75rem;
+  border: 1px solid var(--border);
+}
+
+.pull-request-section-heading h2 {
+  margin: 0;
+  font-size: 1.25rem;
+}
+
+.pull-request-change-summary {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 0;
+  margin-block: 1rem;
+  border: 1px solid var(--border);
+}
+
+.pull-request-change-summary > div {
+  min-width: 0;
+  padding: 0.75rem;
+  border-right: 1px solid var(--border);
+}
+
+.pull-request-change-summary > div:last-child {
+  border-right: 0;
+}
+
+.pull-request-change-summary dt,
+.pull-request-change-summary dd {
+  display: block;
+}
+
+.pull-request-change-summary dd {
+  margin-top: 0.15rem;
+  font-weight: 700;
+}
+
+.pull-request-change-index,
+.pull-request-history-grid,
+.pull-request-decision-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 1rem;
+}
+
+.pull-request-change-index > div,
+.pull-request-history-grid > section {
+  min-width: 0;
+  padding: 0.85rem;
+  border: 1px solid var(--border);
+  background: var(--surface);
+}
+
+.pull-request-change-index h3,
+.pull-request-history-grid h2 {
+  margin: 0 0 0.5rem;
+}
+
+.pull-request-change-index ol,
+.pull-request-change-index ul,
+.pull-request-history-grid ol {
+  margin: 0;
+  padding-left: 1.4rem;
+}
+
+.pull-request-change-index li,
+.pull-request-history-grid li {
+  margin-block: 0.35rem;
+}
+
+.pull-request-diff {
+  display: grid;
+  gap: 1rem;
+  margin-top: 1rem;
+}
+
+.pull-request-file {
+  min-width: 0;
+  border: 1px solid var(--border);
+  background: var(--surface);
+}
+
+.pull-request-file > h3 {
+  margin: 0;
+  padding: 0.65rem 0.85rem;
+  border-bottom: 1px solid var(--border);
+}
+
+.pull-request-file > pre {
+  margin: 0;
+  border: 0;
+  background: var(--bg);
+}
+
+.pull-request-line-comment {
+  margin: 0;
+  padding: 0.65rem 0.85rem;
+  border-top: 1px solid var(--border);
+}
+
+.pull-request-line-comment form {
+  max-width: 42rem;
+}
+
+.pull-request-line-fields {
+  display: grid;
+  grid-template-columns: minmax(8rem, 0.4fr) minmax(8rem, 0.6fr);
+  gap: 0.75rem;
+}
+
+.pull-request-discussion {
+  display: grid;
+  gap: 0.75rem;
+  margin-top: 1rem;
+}
+
+.pull-request-discussion .comment-card {
+  margin: 0;
+}
+
+.pull-request-discussion .comment-card > header {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: space-between;
+  gap: 0.5rem 1rem;
+  padding-bottom: 0.55rem;
+  border-bottom: 1px solid var(--border);
+}
+
+.pull-request-discussion .comment-card > header span,
+.pull-request-comment-anchor,
+.pull-request-empty {
+  color: var(--fg-muted);
+  font-size: 0.7rem;
+}
+
+.pull-request-decision-grid > .pull-request-section {
+  min-width: 0;
+}
+
+.pull-request-decision-grid form {
+  max-width: none;
+}
+
+.pull-request-review-actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.5rem;
+}
+
+.pull-request-merge {
+  display: flex;
+  flex-direction: column;
+}
+
+.pull-request-merge form {
+  margin-top: auto;
+}
+
+.pull-request-history {
+  margin: 0;
+}
+
+.pull-request-history > summary {
+  color: var(--fg-dim);
+}
+
+.pull-request-history[open] > summary {
+  margin-bottom: 1rem;
+  color: var(--fg);
+}
+
+.pull-request-history-grid li > span {
+  display: block;
+  color: var(--fg-muted);
+  font-size: 0.7rem;
+}
+
+.pull-request-git-details {
+  display: grid;
+  gap: 0.35rem;
+  margin-top: 1rem;
+  padding: 0.75rem;
+  border: 1px solid var(--border);
+  background: var(--surface);
+}
+
+.pull-request-git-details span {
+  color: var(--fg-muted);
+  font-size: 0.7rem;
+}
+
+.pull-request-git-details code {
+  overflow-x: auto;
+  white-space: nowrap;
+}
+
+.profile-bio,
+.preserve-whitespace {
   white-space: pre-wrap;
   overflow-wrap: anywhere;
 }
 
-.home-intro {
-  align-self: stretch;
-}
-
-.home-actions {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 0.5rem 1rem;
-  margin-top: 1.5rem;
-}
-
-.home-repository-list {
-  margin: 0;
-  padding: 0;
-  list-style: none;
-}
-
-.home-repository-list li {
-  padding-block: 0.75rem;
-}
-
-.home-repository-list li + li {
-  border-top: 1px solid GrayText;
-}
-
-.repository-name {
-  font-weight: 700;
-}
-
-.repository-meta {
-  display: block;
-  margin-top: 0.25rem;
-}
-
-.home-repository-list p {
-  margin: 0.25rem 0 0;
-}
-
-.home-repository-list .inline-form {
-  margin-top: 0.75rem;
-}
-
-.home-workflow {
-  margin-top: 2rem;
-}
-
-.home-workflow ol {
-  display: grid;
-  grid-template-columns: repeat(3, minmax(0, 1fr));
-  gap: 2rem;
-  margin-bottom: 0;
-  padding-left: 1.5rem;
-}
-
-.home-workflow strong {
-  display: block;
-  margin-bottom: 0.5rem;
-}
-
-.home-workflow p {
-  margin: 0;
+.field-help {
+  color: var(--fg-dim);
+  font-size: 0.8rem;
 }
 
 .auth-page {
-  width: min(100%, 36rem);
+  width: min(100%, 38rem);
   margin-inline: auto;
 }
 
-.auth-panel {
-  padding: 1rem;
-  border: 1px solid GrayText;
-  border-radius: 0.25rem;
+.account-entry {
+  max-width: none;
 }
 
-.auth-panel > :first-child {
+.account-entry > p {
+  color: var(--fg-dim);
+}
+
+.account-options {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 0.75rem;
+  margin-top: 2rem;
+}
+
+.account-options a {
+  display: flex;
+  min-height: 12rem;
+  padding: 1rem;
+  border: 1px solid var(--border);
+  background: var(--panel);
+  flex-direction: column;
+}
+
+.account-options a:hover {
+  border-color: var(--border-strong);
+  text-decoration: none;
+}
+
+.account-options svg {
+  width: 1.75rem;
+  height: 1.75rem;
+  margin-bottom: auto;
+}
+
+.account-options strong {
+  margin-top: 2rem;
+}
+
+.account-options span {
+  margin-top: 0.35rem;
+  color: var(--fg-dim);
+  font-size: 0.8rem;
+}
+
+.account-utilities {
+  gap: 0.75rem;
+}
+
+.account-key-layout {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.8fr);
+  gap: 2rem;
+  align-items: start;
+}
+
+.account-key-layout > * {
+  min-width: 0;
+}
+
+.account-key-layout h3 {
   margin-top: 0;
 }
 
-.auth-panel > :last-child {
-  margin-bottom: 0;
-}
-
-.auth-panel form {
+.auth-panel form,
+.search-panel form {
   max-width: none;
 }
 
 .auth-panel details {
   margin-bottom: 0;
   padding-top: 1rem;
-  border-top: 1px solid GrayText;
-}
-
-.search-panel form {
-  max-width: none;
+  border-top: 1px solid var(--border);
 }
 
 .search-results {
@@ -459,10 +1229,7 @@
 
 .search-results li {
   padding-block: 0.75rem;
-}
-
-.search-results li + li {
-  border-top: 1px solid GrayText;
+  border-bottom: 1px solid var(--border);
 }
 
 .page-section-header {
@@ -486,7 +1253,7 @@
   max-width: none;
   margin-bottom: 0;
   padding-bottom: 1rem;
-  border-bottom: 1px solid GrayText;
+  border-bottom: 1px solid var(--border);
 }
 
 .filter-form .field {
@@ -497,66 +1264,39 @@
   min-width: 9rem;
 }
 
-.issue-list {
-  margin: 0;
-  padding: 0;
-  list-style: none;
-}
-
-.issue-list li {
-  padding-block: 1rem;
-}
-
-.issue-list li + li {
-  border-top: 1px solid GrayText;
-}
-
-.issue-title {
-  font-weight: 700;
-}
-
-.issue-meta {
-  display: block;
-  margin-top: 0.25rem;
-}
-
 .collection-action {
   margin-bottom: 0;
   padding-top: 1rem;
-  border-top: 1px solid GrayText;
-}
-
-@media (max-width: 44rem) {
-  .two-column,
-  .repository-summary-layout {
-    grid-template-columns: 1fr;
-  }
-
-  .home-workflow ol {
-    grid-template-columns: 1fr;
-    gap: 1rem;
-  }
+  border-top: 1px solid var(--border);
 }
 
 .pagination {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 1rem;
+  margin-top: 1.5rem;
 }
 
 .confirmation {
   margin-block: 0.75rem;
-  border: 1px solid GrayText;
+  border: 1px solid var(--border);
 }
 
 .confirmation label {
   display: inline-block;
   margin-right: 1rem;
+  color: var(--fg);
   font-weight: 400;
 }
 
+.inline-form {
+  display: inline;
+}
+
+.inline-form button {
+  padding: 0.3rem 0.5rem;
+  font-size: 0.7rem;
+}
+
 .skip-link {
-  position: absolute;
+  position: fixed;
   left: -10000px;
   top: auto;
 }
@@ -564,7 +1304,212 @@
 .skip-link:focus {
   left: 1rem;
   top: 1rem;
+  z-index: 10;
+  padding: 0.5rem 0.75rem;
+  background: var(--fg);
+  color: var(--bg);
+}
+
+.background-icon {
+  position: fixed;
+  right: -5%;
+  bottom: -5%;
+  z-index: 0;
+  width: 100vw;
+  height: 100vw;
+  max-width: 50rem;
+  max-height: 50rem;
+  color: var(--fg);
+  opacity: 0.08;
+  pointer-events: none;
+  transform: rotate(-15deg);
+}
+
+.background-icon svg {
+  width: 100%;
+  height: 100%;
+  fill: none;
+  stroke: currentColor;
+  stroke-width: 1;
+  stroke-linecap: round;
+  stroke-linejoin: round;
+}
+
+.site-header,
+.site-header-rule,
+main,
+footer {
+  position: relative;
   z-index: 1;
-  padding: 0.5rem;
-  background: Canvas;
+}
+
+@media (max-width: 52rem) {
+  .two-column,
+  .repository-summary-layout,
+  .account-key-layout,
+  .pull-request-change-index,
+  .pull-request-history-grid,
+  .pull-request-decision-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .home-intro {
+    min-height: 0;
+  }
+
+  .home-grid > section {
+    height: auto;
+    min-height: 0;
+    overflow: visible;
+  }
+
+  .home-grid .home-repository-list {
+    overflow: visible;
+  }
+}
+
+@media (max-width: 44rem) {
+  body {
+    padding: 2rem;
+  }
+
+  .site-header {
+    align-items: flex-start;
+    padding-block: 2rem;
+  }
+
+  .site-header-rule {
+    margin-bottom: 2rem;
+  }
+
+  .site-header nav {
+    justify-content: flex-start;
+  }
+
+  main {
+    padding-top: 0;
+  }
+
+  .home-workflow ol {
+    grid-template-columns: 1fr;
+  }
+
+  .account-options {
+    grid-template-columns: 1fr;
+  }
+
+  .account-options a {
+    min-height: 9rem;
+  }
+
+  .home-workflow li {
+    min-height: 0;
+  }
+
+  .home-workflow li::before {
+    margin-bottom: 1rem;
+  }
+
+  .repository-navigation {
+    align-items: flex-start;
+  }
+
+  .repository-tabs {
+    flex: 1 1 100%;
+  }
+
+  .repository-tabs a {
+    flex: 1;
+    text-align: center;
+  }
+
+  .repository-file-list,
+  .repository-file-list tbody,
+  .repository-file-list tr,
+  .repository-file-list th,
+  .repository-file-list td {
+    display: block;
+    width: 100%;
+  }
+
+  .repository-file-list thead {
+    display: none;
+  }
+
+  .repository-file-list tr {
+    padding: 0.75rem 0.85rem;
+    border-bottom: 1px solid var(--border);
+  }
+
+  .repository-file-list tbody tr:last-child {
+    border-bottom: 0;
+  }
+
+  .repository-file-list th,
+  .repository-file-list td {
+    padding: 0;
+    border: 0;
+  }
+
+  .repository-file-list td {
+    color: var(--fg-muted);
+    font-size: 0.7rem;
+  }
+
+  .repository-file-list td:nth-child(2)::before {
+    content: "type ";
+  }
+
+  .repository-file-list td:nth-child(3)::before {
+    content: "object ";
+  }
+
+  .pull-request-title,
+  .pull-request-section-heading {
+    flex-direction: column;
+  }
+
+  .pull-request-branches {
+    grid-template-columns: 1fr;
+  }
+
+  .pull-request-direction {
+    transform: rotate(90deg);
+    justify-self: center;
+  }
+
+  .pull-request-change-summary {
+    grid-template-columns: 1fr;
+  }
+
+  .pull-request-change-summary > div {
+    border-right: 0;
+    border-bottom: 1px solid var(--border);
+  }
+
+  .pull-request-change-summary > div:last-child {
+    border-bottom: 0;
+  }
+
+  .pull-request-line-fields {
+    grid-template-columns: 1fr;
+  }
+
+  dl {
+    grid-template-columns: 1fr;
+  }
+}
+
+@media (min-width: 48rem) {
+  body {
+    padding: 3rem;
+  }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  *,
+  *::before,
+  *::after {
+    scroll-behavior: auto !important;
+  }
 }

scripts/package-release

Mode 100755100755; object a73b543597d122af0f939c0d

@@ -62,6 +62,7 @@
     "$root/share/fish/vendor_completions.d"
 install -m 755 "$binary" "$root/bin/tit"
 install -m 644 "$project_dir/LICENSE" "$root/LICENSE"
+install -m 644 "$project_dir/assets/SpaceMono-LICENSE.txt" "$root/LICENSE-SpaceMono"
 install -m 644 "$project_dir/README.md" "$root/README.md"
 install -m 600 "$project_dir/config.example.toml" "$root/etc/tit/config.toml"
 install -m 644 "$project_dir/release/examples/Caddyfile" "$root/share/doc/tit/examples/Caddyfile"

scripts/verify-release-artifact

Mode 100755100755; object 8a8e7a9b2143941685f20ece

@@ -66,6 +66,7 @@
 for required in \
     "$binary" \
     "$package/LICENSE" \
+    "$package/LICENSE-SpaceMono" \
     "$package/README.md" \
     "$package/etc/tit/config.toml" \
     "$package/share/doc/tit/examples/Caddyfile" \

src/git/read.rs

Mode 100644100644; object 85bd77d6a38bf462d340b3ce

@@ -340,10 +340,9 @@
             return Err(ReadError::Limit("tree entries"));
         }
         let truncated = tree.entries.len() > maximum;
-        let entries = tree
+        let mut entries = tree
             .entries
             .into_iter()
-            .take(maximum)
             .map(|entry| {
                 budget.check()?;
                 Ok(TreeEntryInfo {
@@ -354,6 +353,12 @@
                 })
             })
             .collect::<Result<Vec<_>, ReadError>>()?;
+        entries.sort_by(|left, right| {
+            (!matches!(left.kind, EntryKind::Tree))
+                .cmp(&(!matches!(right.kind, EntryKind::Tree)))
+                .then_with(|| left.name.cmp(&right.name))
+        });
+        entries.truncate(maximum);
         Ok((entries, truncated))
     }
 

src/http/issues.rs

Mode 100644100644; object 3d80123a607e632e3f2bd339

@@ -14,7 +14,7 @@
 use super::filters;
 use super::{
     CSRF_COOKIE, RequestActor, RequestId, WebState, authenticate_mutation, cookie,
-    parse_named_form, render, render_error,
+    parse_named_form, render, render_error, repository_can_manage,
 };
 
 const MAX_ISSUE_REQUEST_BYTES: usize = 300 * 1024;
@@ -60,6 +60,13 @@
     let owner = path.owner.clone();
     let repository = path.repository.clone();
     let authenticated = actor.0.is_some();
+    let can_manage = repository_can_manage(
+        state.clone(),
+        actor.0.clone(),
+        owner.clone(),
+        repository.clone(),
+    )
+    .await;
     let state_filter = query.state.unwrap_or_else(|| "open".to_owned());
     let page_number = query.page.unwrap_or(1);
     if page_number == 0 {
@@ -84,6 +91,7 @@
                 &IssueListTemplate {
                     request_id: &request_id.0,
                     signed_in: authenticated,
+                    can_manage,
                     owner: &record.owner,
                     repository: &record.slug,
                     issues: page
@@ -133,6 +141,13 @@
     let owner = path.owner.clone();
     let repository = path.repository.clone();
     let number = path.number;
+    let can_manage = repository_can_manage(
+        state.clone(),
+        actor.0.clone(),
+        owner.clone(),
+        repository.clone(),
+    )
+    .await;
     let comments_page = query.comments_page.unwrap_or(1);
     let timeline_page = query.timeline_page.unwrap_or(1);
     if comments_page == 0 || timeline_page == 0 {
@@ -150,7 +165,7 @@
     })
     .await;
     match result {
-        Ok(detail) => render_issue(&request_id.0, &headers, &detail),
+        Ok(detail) => render_issue(&request_id.0, &headers, &detail, can_manage),
         Err(error) => issue_read_error(error, &request_id.0),
     }
 }
@@ -309,13 +324,19 @@
     .map_err(|_| IssueError::Store(StoreError::Integrity("issue worker failed".to_owned())))?
 }
 
-fn render_issue(request_id: &str, headers: &HeaderMap, detail: &IssueDetail) -> Response {
+fn render_issue(
+    request_id: &str,
+    headers: &HeaderMap,
+    detail: &IssueDetail,
+    can_manage: bool,
+) -> Response {
     let csrf = cookie(headers, CSRF_COOKIE).unwrap_or_default();
     render(
         StatusCode::OK,
         &IssueTemplate {
             request_id,
             signed_in: !csrf.is_empty(),
+            can_manage,
             owner: &detail.repository.owner,
             repository: &detail.repository.slug,
             number: detail.issue.number,
@@ -479,6 +500,7 @@
 struct IssueListTemplate<'a> {
     request_id: &'a str,
     signed_in: bool,
+    can_manage: bool,
     owner: &'a str,
     repository: &'a str,
     issues: Vec<IssueListItem<'a>>,
@@ -507,6 +529,7 @@
 struct IssueTemplate<'a> {
     request_id: &'a str,
     signed_in: bool,
+    can_manage: bool,
     owner: &'a str,
     repository: &'a str,
     number: i64,

src/http/mod.rs

Mode 100644100644; object 17f41572479c169a072788cb

@@ -45,7 +45,9 @@
 use self::public::PublicWeb;
 
 const STYLE: &str = include_str!("../../assets/style.css");
-const CONTENT_SECURITY_POLICY: &str = "default-src 'none'; style-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'";
+const SPACE_MONO_REGULAR: &[u8] = include_bytes!("../../assets/SpaceMono-Regular.ttf");
+const SPACE_MONO_BOLD: &[u8] = include_bytes!("../../assets/SpaceMono-Bold.ttf");
+const CONTENT_SECURITY_POLICY: &str = "default-src 'none'; style-src 'self'; font-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'";
 const MAX_BLOCKING_WEB_JOBS: usize = 8;
 const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
 const CONCURRENCY_WAIT: Duration = Duration::from_secs(1);
@@ -445,6 +447,8 @@
                 .layer(DefaultBodyLimit::max(1024)),
         )
         .route("/assets/style.css", get(style))
+        .route("/assets/SpaceMono-Regular.ttf", get(space_mono_regular))
+        .route("/assets/SpaceMono-Bold.ttf", get(space_mono_bold))
         .merge(feeds::routes())
         .merge(repository_routes)
         .route("/{username}", get(public_profile))
@@ -1220,7 +1224,13 @@
     headers: HeaderMap,
 ) -> Response {
     let Some(session_token) = cookie(&headers, SESSION_COOKIE) else {
-        return login_redirect(false);
+        return render(
+            StatusCode::OK,
+            &AccountEntryTemplate {
+                request_id: &request_id.0,
+                signed_in: false,
+            },
+        );
     };
     let Some(csrf) = cookie(&headers, CSRF_COOKIE) else {
         return login_redirect(true);
@@ -1813,6 +1823,22 @@
     })?
 }
 
+async fn repository_can_manage(
+    state: WebState,
+    actor: Option<String>,
+    owner: String,
+    repository: String,
+) -> bool {
+    let Some(actor) = actor else {
+        return false;
+    };
+    repository_job(state, move |repositories| {
+        repositories.can_manage(&owner, &repository, &actor)
+    })
+    .await
+    .unwrap_or(false)
+}
+
 async fn authenticate_mutation(
     state: WebState,
     headers: &HeaderMap,
@@ -2112,6 +2138,23 @@
         .header(header::CACHE_CONTROL, "no-cache")
         .body(Body::from(STYLE))
         .expect("the embedded CSS response is valid")
+}
+
+async fn space_mono_regular() -> Response {
+    font_asset(SPACE_MONO_REGULAR)
+}
+
+async fn space_mono_bold() -> Response {
+    font_asset(SPACE_MONO_BOLD)
+}
+
+fn font_asset(bytes: &'static [u8]) -> Response {
+    Response::builder()
+        .status(StatusCode::OK)
+        .header(header::CONTENT_TYPE, "font/ttf")
+        .header(header::CACHE_CONTROL, "no-cache")
+        .body(Body::from(Bytes::from_static(bytes)))
+        .expect("the embedded font response is valid")
 }
 
 async fn not_found(
@@ -2433,6 +2476,13 @@
     contact_email: &'a str,
     keys: Vec<AccountKeyView<'a>>,
     active_key_count: usize,
+    signed_in: bool,
+}
+
+#[derive(Template)]
+#[template(path = "account-entry.html")]
+struct AccountEntryTemplate<'a> {
+    request_id: &'a str,
     signed_in: bool,
 }
 

src/http/public.rs

Mode 100644100644; object aca97529f24b8f28934fa8a9

@@ -146,6 +146,38 @@
         .map_err(|_| RouteError::Internal)?
     }
 
+    async fn read_page<F>(
+        &self,
+        actor: Option<String>,
+        owner: String,
+        repository: String,
+        operation: F,
+    ) -> Result<RepositoryPage, RouteError>
+    where
+        F: FnOnce(RepositoryRecord, RepositoryReadService) -> Result<RepositoryPage, RouteError>
+            + Send
+            + 'static,
+    {
+        let policy = self.policy.clone();
+        let permission_actor = actor.clone();
+        let permission_owner = owner.clone();
+        let permission_repository = repository.clone();
+        self.read(actor, owner, repository, move |record, service| {
+            let can_manage = policy
+                .authorize(
+                    permission_actor.as_deref(),
+                    &permission_owner,
+                    &permission_repository,
+                    RepositoryOperation::Maintain,
+                )
+                .is_ok();
+            let mut page = operation(record, service)?;
+            page.can_manage = can_manage;
+            Ok(page)
+        })
+        .await
+    }
+
     async fn event_page(
         &self,
         actor: Option<String>,
@@ -426,7 +458,7 @@
     let clone_urls = web.clone_urls(&path.owner, &path.repository);
     let database = web.database.clone();
     let result = web
-        .read(
+        .read_page(
             actor.0,
             path.owner,
             path.repository,
@@ -517,7 +549,7 @@
     };
     let signed_in = actor.0.is_some();
     let result = web
-        .read(
+        .read_page(
             actor.0,
             path.owner,
             path.repository,
@@ -548,7 +580,7 @@
         return route_error(RouteError::InvalidRequest, &request_id.0);
     }
     let result = web
-        .read(
+        .read_page(
             actor.0,
             path.owner,
             path.repository,
@@ -590,7 +622,7 @@
     let signed_in = actor.0.is_some();
     let database = web.database.clone();
     let result = web
-        .read(
+        .read_page(
             actor.0,
             path.owner,
             path.repository,
@@ -639,7 +671,7 @@
     };
     let signed_in = actor.0.is_some();
     let result = web
-        .read(
+        .read_page(
             actor.0,
             path.owner,
             path.repository,
@@ -715,7 +747,7 @@
     };
     let signed_in = actor.0.is_some();
     let result = web
-        .read(
+        .read_page(
             actor.0,
             path.owner,
             path.repository,
@@ -769,7 +801,7 @@
     };
     let signed_in = actor.0.is_some();
     let result = web
-        .read(
+        .read_page(
             actor.0,
             path.owner,
             path.repository,
@@ -803,7 +835,7 @@
     };
     let signed_in = actor.0.is_some();
     let result = web
-        .read(
+        .read_page(
             actor.0,
             path.owner,
             path.repository,
@@ -886,7 +918,7 @@
     };
     let signed_in = actor.0.is_some();
     let result = web
-        .read(
+        .read_page(
             actor.0,
             path.owner,
             path.repository,
@@ -1409,6 +1441,7 @@
 struct RepositoryPage {
     request_id: String,
     signed_in: bool,
+    can_manage: bool,
     owner: String,
     repository: String,
     created_at: i64,
@@ -1470,6 +1503,7 @@
         Self {
             request_id: String::new(),
             signed_in: false,
+            can_manage: false,
             owner: record.owner,
             repository: record.slug,
             created_at: record.created_at,

src/http/pull_requests.rs

Mode 100644100644; object d0df38dc0b8f75be0389218b

@@ -18,7 +18,7 @@
 use super::public::{patch_response, stream_patch};
 use super::{
     CSRF_COOKIE, RequestActor, RequestId, WebState, authenticate_mutation, cookie,
-    parse_named_form, render, render_error,
+    parse_named_form, render, render_error, repository_can_manage,
 };
 
 const MAX_PULL_REQUEST_BYTES: usize = 300 * 1024;
@@ -126,6 +126,13 @@
     let repository = path.repository.clone();
     let signed_in = actor.0.is_some();
     let actor_name = actor.0;
+    let can_manage = repository_can_manage(
+        state.clone(),
+        actor_name.clone(),
+        owner.clone(),
+        repository.clone(),
+    )
+    .await;
     let actor_for_list = actor_name.clone();
     let state_filter = query.state.unwrap_or_else(|| "open".to_owned());
     let page_number = query.page.unwrap_or(1);
@@ -171,6 +178,7 @@
                 &PullRequestListTemplate {
                     request_id: &request_id.0,
                     signed_in,
+                    can_manage,
                     owner: &record.owner,
                     repository: &record.slug,
                     pull_requests: page
@@ -218,6 +226,13 @@
     let owner = path.owner.clone();
     let repository = path.repository.clone();
     let signed_in = actor.0.is_some();
+    let can_manage = repository_can_manage(
+        state.clone(),
+        actor.0.clone(),
+        owner.clone(),
+        repository.clone(),
+    )
+    .await;
     let reviews_page = query.reviews_page.unwrap_or(1);
     let timeline_page = query.timeline_page.unwrap_or(1);
     if reviews_page == 0 || timeline_page == 0 {
@@ -247,6 +262,7 @@
                 &PullRequestTemplate {
                     request_id: &request_id.0,
                     signed_in,
+                    can_manage,
                     owner: &detail.repository.owner,
                     repository: &detail.repository.slug,
                     pull_request,
@@ -257,7 +273,6 @@
                         .iter()
                         .map(|review| ReviewView {
                             id: &review.id,
-                            revision: review.revision,
                             author: &review.author,
                             kind: &review.kind,
                             body_html: markdown::render(&review.body),
@@ -723,6 +738,7 @@
 struct PullRequestListTemplate<'a> {
     request_id: &'a str,
     signed_in: bool,
+    can_manage: bool,
     owner: &'a str,
     repository: &'a str,
     pull_requests: Vec<PullRequestListItem<'a>>,
@@ -754,6 +770,7 @@
 struct PullRequestTemplate<'a> {
     request_id: &'a str,
     signed_in: bool,
+    can_manage: bool,
     owner: &'a str,
     repository: &'a str,
     pull_request: &'a crate::store::PullRequestRecord,
@@ -784,7 +801,6 @@
 
 struct ReviewView<'a> {
     id: &'a str,
-    revision: i64,
     author: &'a str,
     kind: &'a str,
     body_html: RenderedMarkdown,

src/http/repository_settings.rs

Mode 100644100644; object 26c76f1dbc38aee97b0c072a

@@ -66,6 +66,7 @@
                 &RepositorySettingsTemplate {
                     request_id: &request_id.0,
                     signed_in: true,
+                    can_manage: true,
                     owner: &settings.repository.owner,
                     repository: &settings.repository.slug,
                     description: &settings.description,
@@ -410,6 +411,7 @@
 struct RepositorySettingsTemplate<'a> {
     request_id: &'a str,
     signed_in: bool,
+    can_manage: bool,
     owner: &'a str,
     repository: &'a str,
     description: &'a str,

src/http/watches.rs

Mode 100644100644; object b6b5964493173279ac858070

@@ -12,7 +12,7 @@
 
 use super::{
     CSRF_COOKIE, RequestActor, RequestId, WebState, authenticate_mutation, cookie,
-    parse_named_form, render, render_error,
+    parse_named_form, render, render_error, repository_can_manage,
 };
 
 pub(super) fn routes() -> Router<WebState> {
@@ -37,6 +37,13 @@
     let owner = path.owner;
     let repository = path.repository;
     let authenticated = actor.0.is_some();
+    let can_manage = repository_can_manage(
+        state.clone(),
+        actor.0.clone(),
+        owner.clone(),
+        repository.clone(),
+    )
+    .await;
     let result = watch_job(state, move || {
         service.get(&owner, &repository, actor.0.as_deref())
     })
@@ -50,6 +57,7 @@
                 &WatchTemplate {
                     request_id: &request_id.0,
                     signed_in: authenticated,
+                    can_manage,
                     owner: &record.owner,
                     repository: &record.slug,
                     csrf: &csrf,
@@ -168,6 +176,7 @@
 struct WatchTemplate<'a> {
     request_id: &'a str,
     signed_in: bool,
+    can_manage: bool,
     owner: &'a str,
     repository: &'a str,
     csrf: &'a str,

src/repository.rs

Mode 100644100644; object 22dc3024837230b2486ea15e

@@ -71,6 +71,20 @@
             .map_err(Into::into)
     }
 
+    pub(crate) fn can_manage(
+        &self,
+        owner: &str,
+        repository: &str,
+        actor: &str,
+    ) -> Result<bool, RepositoryServiceError> {
+        validate_username(owner)?;
+        validate_slug(repository)?;
+        validate_username(actor)?;
+        Store::open(&self.database)?
+            .repository_can_maintain(owner, repository, actor)
+            .map_err(Into::into)
+    }
+
     pub(crate) fn settings(
         &self,
         owner: &str,

src/store/mod.rs

Mode 100644100644; object 40324df2ab4dbb8a44bddc39

@@ -1605,6 +1605,15 @@
         })
     }
 
+    pub(crate) fn repository_can_maintain(
+        &self,
+        owner: &str,
+        slug: &str,
+        actor: &str,
+    ) -> Result<bool, StoreError> {
+        Ok(repository_issue_access(&self.connection, owner, slug, Some(actor))?.can_maintain())
+    }
+
     pub(crate) fn repository_default_branch(
         &self,
         owner: &str,

templates/account-entry.html

Mode 100644; object b9249f77d20f

@@ -1,0 +1,40 @@
+{% extends "base.html" %}
+{% block title %}Account · tit{% endblock %}
+{% block content %}
+  <section class="account-entry" aria-labelledby="account-heading">
+    <h1 id="account-heading">Account</h1>
+    <p>Create an account, recover an account, or log in with an SSH key.</p>
+    <div class="account-options">
+      <a href="/login">
+        <svg viewBox="0 0 24 24" aria-hidden="true">
+          <path d="M10 5H5v14h5"/>
+          <path d="M14 8l4 4-4 4"/>
+          <path d="M8 12h10"/>
+        </svg>
+        <strong>Log in</strong>
+        <span>Use an SSH key that is active on your account.</span>
+      </a>
+      <a href="/signup">
+        <svg viewBox="0 0 24 24" aria-hidden="true">
+          <circle cx="9" cy="8" r="4"/>
+          <path d="M2 21a7 7 0 0 1 14 0"/>
+          <path d="M19 8v6"/>
+          <path d="M16 11h6"/>
+        </svg>
+        <strong>Create account</strong>
+        <span>Use a signup code and an SSH public key.</span>
+      </a>
+      <a href="/recover">
+        <svg viewBox="0 0 24 24" aria-hidden="true">
+          <circle cx="12" cy="12" r="3"/>
+          <path d="M15 12h7"/>
+          <path d="M19 12v3"/>
+          <path d="M22 12v2"/>
+          <path d="M9 12H5a3 3 0 1 1 0-6h5"/>
+        </svg>
+        <strong>Recover account</strong>
+        <span>Replace all SSH keys with a recovery code.</span>
+      </a>
+    </div>
+  </section>
+{% endblock %}

templates/account-page.html

Mode 100644100644; object 52279b8a8c76548daa5cea7c

@@ -35,11 +35,22 @@
     </p>
     <button type="submit">Create repository</button>
   </form>
+  </section>
+  </div>
+  <div class="two-column account-utilities">
+  <section>
   <h2>Feeds</h2>
   <p><a href="/feeds">Manage private feed tokens</a></p>
+  </section>
+  <section>
   <h2>Sessions</h2>
-  <p><a href="/logout">Log out all sessions</a></p>
-  <h2>SSH keys</h2>
+  <p><a class="danger-link" href="/logout">Log out all sessions</a></p>
+  </section>
+  </div>
+  <section class="account-keys" aria-labelledby="ssh-keys-heading">
+  <h2 id="ssh-keys-heading">SSH keys</h2>
+  <div class="account-key-layout">
+  <div>
   <ul class="key-list">
     {% for key in keys %}
     <li class="key-card">
@@ -59,7 +70,7 @@
       <form action="/account/keys/revoke" method="post">
         <input type="hidden" name="csrf" value="{{ csrf }}">
         <input type="hidden" name="fingerprint" value="{{ key.fingerprint }}">
-        <button type="submit">Revoke</button>
+        <button class="danger-action" type="submit">Revoke</button>
       </form>
           {% else %}
       <p>This is your final active key.</p>
@@ -68,6 +79,8 @@
     </li>
     {% endfor %}
   </ul>
+  </div>
+  <div>
   <h3>Add SSH key</h3>
   <form action="/account/keys/add" method="post">
     <input type="hidden" name="csrf" value="{{ csrf }}">
@@ -81,6 +94,7 @@
     </div>
     <button type="submit">Add key</button>
   </form>
-  </section>
   </div>
+  </div>
+  </section>
 {% endblock %}

templates/base.html

Mode 100644100644; object 2f962c1fde840450682bce93

@@ -4,30 +4,80 @@
   <meta charset="utf-8">
   <meta name="viewport" content="width=device-width, initial-scale=1">
   <title>{% block title %}tit{% endblock %}</title>
-  <link rel="stylesheet" href="/assets/style.css?v=2">
+  <link rel="stylesheet" href="/assets/style.css?v=10">
 </head>
 <body>
   <a class="skip-link" href="#main">Skip to content</a>
+  <div class="background-icon" aria-hidden="true">
+    <svg viewBox="0 0 24 24">
+      <circle cx="6" cy="5" r="2"/>
+      <circle cx="6" cy="19" r="2"/>
+      <circle cx="18" cy="12" r="2"/>
+      <path d="M6 7v10"/>
+      <path d="M8 7c0 3 2 5 8 5"/>
+    </svg>
+  </div>
   <header class="site-header">
-    <a class="brand" href="/" aria-label="tit home">tit</a>
+    <a class="brand" href="/" aria-label="tit home">
+      <svg class="brand-icon" viewBox="0 0 24 24" aria-hidden="true">
+        <circle cx="6" cy="5" r="2"/>
+        <circle cx="6" cy="19" r="2"/>
+        <circle cx="18" cy="12" r="2"/>
+        <path d="M6 7v10"/>
+        <path d="M8 7c0 3 2 5 8 5"/>
+      </svg>
+      tit
+    </a>
     <nav aria-label="Primary">
-      <a href="/">Home</a>
-      <a href="/search">Search</a>
+      <a class="icon-link" href="/" aria-label="Home" title="Home">
+        <svg viewBox="0 0 24 24" aria-hidden="true">
+          <path d="M3 11.5 12 4l9 7.5"/>
+          <path d="M5.5 10.5V20h13v-9.5"/>
+          <path d="M9.5 20v-6h5v6"/>
+        </svg>
+      </a>
+      <a class="icon-link" href="/search" aria-label="Search" title="Search">
+        <svg viewBox="0 0 24 24" aria-hidden="true">
+          <circle cx="11" cy="11" r="7"/>
+          <path d="m20 20-4-4"/>
+        </svg>
+      </a>
 {% if signed_in %}
-      <a href="/activity">Activity</a>
-      <a href="/account">Account</a>
-      <a href="/logout">Log out</a>
+      <a class="icon-link" href="/activity" aria-label="Activity" title="Activity">
+        <svg viewBox="0 0 24 24" aria-hidden="true">
+          <path d="M4 18h16"/>
+          <path d="m5 14 4-4 3 3 6-7 2 2"/>
+        </svg>
+      </a>
+      <a class="icon-link" href="/account" aria-label="Account" title="Account">
+        <svg viewBox="0 0 24 24" aria-hidden="true">
+          <circle cx="12" cy="8" r="4"/>
+          <path d="M4 21a8 8 0 0 1 16 0"/>
+        </svg>
+      </a>
+      <a class="icon-link" href="/logout" aria-label="Log out" title="Log out">
+        <svg viewBox="0 0 24 24" aria-hidden="true">
+          <path d="M10 5H5v14h5"/>
+          <path d="M14 8l4 4-4 4"/>
+          <path d="M8 12h10"/>
+        </svg>
+      </a>
 {% else %}
-      <a href="/signup">Create account</a>
-      <a href="/recover">Recover account</a>
-      <a href="/login">Log in</a>
+      <a class="icon-link" href="/account" aria-label="Account" title="Account">
+        <svg viewBox="0 0 24 24" aria-hidden="true">
+          <circle cx="12" cy="8" r="4"/>
+          <path d="M4 21a8 8 0 0 1 16 0"/>
+        </svg>
+      </a>
 {% endif %}
     </nav>
   </header>
+  <hr class="site-header-rule">
   <main id="main">
 {% block content %}{% endblock %}
   </main>
   <footer>
+    <small>tit · open source · MIT licensed</small>
     <small>Request <code>{{ request_id }}</code></small>
   </footer>
 </body>

templates/feed-tokens.html

Mode 100644100644; object 0b66bd11db8a93359aeb34e7

@@ -26,7 +26,7 @@
           <label><input type="radio" name="confirm" value="no" checked> No</label>
           <label><input type="radio" name="confirm" value="yes"> Yes</label>
         </fieldset>
-        <button type="submit">Rotate</button>
+        <button class="danger-action" type="submit">Rotate</button>
       </form>
       <form method="post" action="/feeds/tokens/{{ token.id }}/revoke">
         <input type="hidden" name="csrf" value="{{ csrf }}">
@@ -35,7 +35,7 @@
           <label><input type="radio" name="confirm" value="no" checked> No</label>
           <label><input type="radio" name="confirm" value="yes"> Yes</label>
         </fieldset>
-        <button type="submit">Revoke</button>
+        <button class="danger-action" type="submit">Revoke</button>
       </form>
 {% else %}
       Revoked.

templates/home.html

Mode 100644100644; object dd6b420bd0ed4c3775025c25

@@ -1,7 +1,7 @@
 {% extends "base.html" %}
 {% block title %}{% if signed_in %}{{ username }}{% else %}Repositories{% endif %} · tit{% endblock %}
 {% block content %}
-  <div class="two-column">
+  <div class="two-column home-grid">
 {% if signed_in %}
   <section>
     <h1>{{ username }}</h1>
@@ -39,15 +39,10 @@
   <section class="home-intro">
     <h1>A small Git CDE</h1>
     <p>tit provides self-hosted Git repositories, issues, pull requests, and code review in one server.</p>
-    <nav class="home-actions" aria-label="Get started">
-      <a href="/signup">Create account</a>
-      <a href="/login">Log in</a>
-      <a href="/search">Search repositories</a>
-    </nav>
   </section>
 {% endif %}
   <section>
-    <h2>Recently updated public repositories</h2>
+    <h2>Recently updated</h2>
 {% if recent_repositories.is_empty() %}
 {% if signed_in %}
     <p>No public repositories are available.</p>

templates/issue.html

Mode 100644100644; object 550ff0bb46f4b622e9c5d137

@@ -3,6 +3,7 @@
 {% block content %}
   <header class="repository-header">
     <h1><a href="/{{ owner }}">{{ owner }}</a>/<a href="/{{ owner }}/{{ repository }}">{{ repository }}</a></h1>
+{% let nav_section = "issues" %}
 {% include "repository-nav.html" %}
   </header>
 
@@ -25,7 +26,7 @@
 {% endfor %}
 {% endif %}
 {% if comments_has_previous || comments_has_next %}
-    <nav aria-label="Comment pages">
+    <nav class="pagination" aria-label="Comment pages">
 {% if comments_has_previous %}
       <a href="?comments_page={{ comments_previous_page }}&amp;timeline_page={{ timeline_page }}">Newer comments</a>
 {% endif %}
@@ -44,7 +45,7 @@
 {% endfor %}
     </ol>
 {% if timeline_has_previous || timeline_has_next %}
-    <nav aria-label="Timeline pages">
+    <nav class="pagination" aria-label="Timeline pages">
 {% if timeline_has_previous %}
       <a href="?comments_page={{ comments_page }}&amp;timeline_page={{ timeline_previous_page }}">Newer events</a>
 {% endif %}

templates/issues.html

Mode 100644100644; object f331f9b5713b5f675dbd0a67

@@ -3,12 +3,15 @@
 {% block content %}
   <header class="repository-header">
     <h1><a href="/{{ owner }}">{{ owner }}</a>/<a href="/{{ owner }}/{{ repository }}">{{ repository }}</a></h1>
+{% let nav_section = "issues" %}
 {% include "repository-nav.html" %}
   </header>
 
   <header class="page-section-header">
     <h2>Issues</h2>
-    <a href="/{{ owner }}/{{ repository }}/issues/rss.xml">Issue RSS</a>
+    <a class="icon-link" href="/{{ owner }}/{{ repository }}/issues/rss.xml" aria-label="Issue RSS feed" title="Issue RSS feed">
+      <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="5" cy="19" r="1"></circle><path d="M4 11a9 9 0 0 1 9 9M4 5a15 15 0 0 1 15 15"></path></svg>
+    </a>
   </header>
   <section class="collection-panel" aria-label="Issues">
     <form class="filter-form" method="get" action="/{{ owner }}/{{ repository }}/issues">

templates/logout.html

Mode 100644100644; object 59b4d59d6bb19a7b6aeadeb8

@@ -10,7 +10,7 @@
       <label><input type="radio" name="confirm" value="no" checked> No</label>
       <label><input type="radio" name="confirm" value="yes"> Yes</label>
     </fieldset>
-    <button type="submit">Log out</button>
+    <button class="danger-action" type="submit">Log out</button>
     <a href="/account">Cancel</a>
   </form>
 {% endblock %}

templates/pull_request.html

Mode 100644100644; object 683b8e256dbd4fd82ff038fa

@@ -3,240 +3,318 @@
 {% block content %}
   <header class="repository-header">
     <h1><a href="/{{ owner }}">{{ owner }}</a>/<a href="/{{ owner }}/{{ repository }}">{{ repository }}</a></h1>
+{% let nav_section = "pulls" %}
 {% include "repository-nav.html" %}
   </header>
 
-  <article>
-    <h2>#{{ pull_request.number }} {{ pull_request.title }}</h2>
-    <p>{{ pull_request.state }} · opened by {{ pull_request.author }} at <time datetime="{{ pull_request.created_at }}">{{ pull_request.created_at|human_time }}</time> · updated <time datetime="{{ pull_request.updated_at }}">{{ pull_request.updated_at|human_time }}</time></p>
-    <p>Base: <code>{{ pull_request.base_ref }}</code> at <code title="{{ pull_request.base_object_id }}">{{ pull_request.base_object_id|short_id }}</code></p>
-    <p>Head: <code>{{ pull_request.head_ref }}</code> at <code title="{{ pull_request.head_object_id }}">{{ pull_request.head_object_id|short_id }}</code></p>
-    <p>Fetch: <code>git fetch origin refs/pull/{{ pull_request.number }}/head</code></p>
-    <div class="markdown">{{ body_html|safe }}</div>
+  <article class="pull-request-page">
+    <header class="pull-request-hero">
+      <div class="pull-request-title">
+        <div>
+          <p class="pull-request-kicker">Pull request #{{ pull_request.number }}</p>
+          <h2>{{ pull_request.title }}</h2>
+        </div>
+        <span class="pull-request-state">{{ pull_request.state }}</span>
+      </div>
+      <p class="pull-request-byline">Opened by <strong>{{ pull_request.author }}</strong> <time datetime="{{ pull_request.created_at }}">{{ pull_request.created_at|human_time }}</time> · updated <time datetime="{{ pull_request.updated_at }}">{{ pull_request.updated_at|human_time }}</time></p>
+      <div class="markdown pull-request-description">{{ body_html|safe }}</div>
+
+      <div class="pull-request-branches" aria-label="Pull request branches">
+        <div>
+          <span>From</span>
+          <code>{{ pull_request.head_ref }}</code>
+          <small title="{{ pull_request.head_object_id }}">{{ pull_request.head_object_id|short_id }}</small>
+        </div>
+        <span class="pull-request-direction" aria-hidden="true">→</span>
+        <div>
+          <span>Into</span>
+          <code>{{ pull_request.base_ref }}</code>
+          <small title="{{ pull_request.base_object_id }}">{{ pull_request.base_object_id|short_id }}</small>
+        </div>
+      </div>
+
+{% if can_edit || can_change_state %}
+      <div class="pull-request-actions">
 {% if can_edit %}
-    <details>
-      <summary>Edit pull request</summary>
-      <form method="post" action="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/edit">
-        <input type="hidden" name="csrf" value="{{ csrf }}">
-        <div class="field">
-          <label for="pull-request-title">Title</label>
-          <input id="pull-request-title" name="title" value="{{ pull_request.title }}" maxlength="200" required>
-        </div>
-        <div class="field">
-          <label for="pull-request-body">Description (Markdown)</label>
-          <textarea id="pull-request-body" name="body" maxlength="262144" rows="10">{{ pull_request.body }}</textarea>
-        </div>
-        <button type="submit">Save pull request</button>
-      </form>
-    </details>
+        <details>
+          <summary>Edit details</summary>
+          <form method="post" action="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/edit">
+            <input type="hidden" name="csrf" value="{{ csrf }}">
+            <div class="field">
+              <label for="pull-request-title">Title</label>
+              <input id="pull-request-title" name="title" value="{{ pull_request.title }}" maxlength="200" required>
+            </div>
+            <div class="field">
+              <label for="pull-request-body">Description (Markdown)</label>
+              <textarea id="pull-request-body" name="body" maxlength="262144" rows="10">{{ pull_request.body }}</textarea>
+            </div>
+            <button type="submit">Save details</button>
+          </form>
+        </details>
 {% endif %}
 {% if can_change_state %}
-    <form method="post" action="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/state">
-      <input type="hidden" name="csrf" value="{{ csrf }}">
+        <form method="post" action="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/state">
+          <input type="hidden" name="csrf" value="{{ csrf }}">
 {% if is_open %}
-      <button type="submit" name="state" value="closed">Close pull request</button>
+          <button class="danger-action" type="submit" name="state" value="closed">Close pull request</button>
 {% else %}
-      <button type="submit" name="state" value="open">Reopen pull request</button>
+          <button type="submit" name="state" value="open">Reopen pull request</button>
 {% endif %}
-    </form>
+        </form>
 {% endif %}
-  </article>
+      </div>
+{% endif %}
+    </header>
 
-  <section aria-labelledby="revisions-heading">
-    <h2 id="revisions-heading">Revisions</h2>
-    <ol>
-{% for revision in revisions %}
-      <li value="{{ revision.number }}">
-        {{ revision.author }} recorded <code title="{{ revision.head_object_id }}">{{ revision.head_object_id|short_id }}</code> against <code title="{{ revision.base_object_id }}">{{ revision.base_object_id|short_id }}</code> at <time datetime="{{ revision.created_at }}">{{ revision.created_at|human_time }}</time>.
-        <a href="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/revisions/{{ revision.number }}.patch">Download patch</a>
-{% if revision.number == selected_revision %}
-        <strong>Selected</strong>
-{% else %}
-        <a href="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}?revision={{ revision.number }}">Compare this revision</a>
-{% endif %}
-      </li>
-{% endfor %}
-    </ol>
-  </section>
+    <section class="pull-request-section" aria-labelledby="changes-heading">
+      <div class="pull-request-section-heading">
+        <div>
+          <p class="pull-request-kicker">Revision {{ selected_revision }}</p>
+          <h2 id="changes-heading">Changes</h2>
+        </div>
+        <span class="pull-request-mergeability">{{ comparison.mergeability }}</span>
+      </div>
 
-  <section aria-labelledby="reviews-heading">
-    <h2 id="reviews-heading">Reviews</h2>
-{% if reviews.is_empty() %}
-    <p>This pull request has no review activity.</p>
-{% else %}
-{% for review in reviews %}
-    <article class="comment-card" id="review-{{ review.id }}">
-      <h3>{{ review.author }}: {{ review.kind|event_name }} at <time datetime="{{ review.created_at }}">{{ review.created_at|human_time }}</time></h3>
-      <p>Revision {{ review.revision }}
-{% if review.kind == "line-comment" %}
-        · <code title="{{ review.commit_object_id }}">{{ review.commit_object_id|short_id }}</code> · <code>{{ review.path }}</code> · {{ review.side }} line {{ review.line }}
-{% if review.outdated %}
-        · <strong>Outdated</strong>
-{% endif %}
-{% endif %}
-      </p>
-{% if review.has_body %}
-      <div class="markdown">{{ review.body_html|safe }}</div>
-{% endif %}
-    </article>
-{% endfor %}
-{% endif %}
-{% if reviews_has_previous || reviews_has_next %}
-    <nav aria-label="Review pages">
-{% if reviews_has_previous %}
-      <a href="?revision={{ selected_revision }}&amp;reviews_page={{ reviews_previous_page }}&amp;timeline_page={{ timeline_page }}">Newer reviews</a>
-{% endif %}
-{% if reviews_has_next %}
-      <a href="?revision={{ selected_revision }}&amp;reviews_page={{ reviews_next_page }}&amp;timeline_page={{ timeline_page }}">Older reviews</a>
-{% endif %}
-    </nav>
-{% endif %}
-  </section>
+      <dl class="pull-request-change-summary">
+        <div>
+          <dt>Commits</dt>
+          <dd>{{ comparison.commits.len() }}</dd>
+        </div>
+        <div>
+          <dt>Files changed</dt>
+          <dd>{{ comparison.files.len() }}</dd>
+        </div>
+        <div>
+          <dt>Merge base</dt>
+          <dd><code title="{{ comparison.merge_base }}">{{ comparison.merge_base|short_id }}</code></dd>
+        </div>
+      </dl>
 
-  <section aria-labelledby="timeline-heading">
-    <h2 id="timeline-heading">Timeline</h2>
-    <ol>
-{% for event in timeline %}
-      <li value="{{ event.sequence }}">{{ event.actor }} {{ event.kind|event_name }} at <time datetime="{{ event.created_at }}">{{ event.created_at|human_time }}</time></li>
-{% endfor %}
-    </ol>
-{% if timeline_has_previous || timeline_has_next %}
-    <nav aria-label="Timeline pages">
-{% if timeline_has_previous %}
-      <a href="?revision={{ selected_revision }}&amp;reviews_page={{ reviews_page }}&amp;timeline_page={{ timeline_previous_page }}">Newer events</a>
-{% endif %}
-{% if timeline_has_next %}
-      <a href="?revision={{ selected_revision }}&amp;reviews_page={{ reviews_page }}&amp;timeline_page={{ timeline_next_page }}">Older events</a>
-{% endif %}
-    </nav>
-{% endif %}
-  </section>
-
-  <section aria-labelledby="comparison-heading">
-    <h2 id="comparison-heading">Comparison for revision {{ selected_revision }}</h2>
-    <p>Merge base: <code title="{{ comparison.merge_base }}">{{ comparison.merge_base|short_id }}</code></p>
-    <p>Mergeability: {{ comparison.mergeability }}</p>
-
-    <h3>Commits</h3>
+      <div class="pull-request-change-index">
+        <div>
+          <h3>Commits</h3>
 {% if comparison.commits.is_empty() %}
-    <p>This revision has no commits that are not in the base.</p>
+          <p>This revision has no commits that are not in the base.</p>
 {% else %}
-    <ol>
+          <ol>
 {% for commit in comparison.commits %}
-      <li><code title="{{ commit.id }}">{{ commit.id|short_id }}</code> <span class="preserve-whitespace">{{ commit.message }}</span></li>
+            <li><code title="{{ commit.id }}">{{ commit.id|short_id }}</code> <span class="preserve-whitespace">{{ commit.message }}</span></li>
 {% endfor %}
-    </ol>
+          </ol>
 {% endif %}
-
-    <h3>Changed paths</h3>
+        </div>
+        <div>
+          <h3>Files</h3>
 {% if comparison.changed_paths.is_empty() %}
-    <p>This revision does not change a path.</p>
+          <p>This revision does not change a path.</p>
 {% else %}
-    <ul>
+          <ul>
 {% for path in comparison.changed_paths %}
-      <li><code>{{ path }}</code></li>
+            <li><code>{{ path }}</code></li>
 {% endfor %}
-    </ul>
+          </ul>
 {% endif %}
+        </div>
+      </div>
 
-    <h3>Diff</h3>
+      <div class="pull-request-diff">
 {% for file in comparison.files %}
-    <article>
-      <h4><code>{{ file.path }}</code></h4>
+        <article class="pull-request-file" id="diff-{{ file.path_hex }}">
+          <h3><code>{{ file.path }}</code></h3>
 {% if file.binary %}
-      <p>This file has binary content.</p>
+          <p>This file has binary content.</p>
 {% else %}
-      <pre>{{ file.hunks }}</pre>
+          <pre>{{ file.hunks }}</pre>
 {% if can_review %}
-      <form method="post" action="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/reviews">
-        <input type="hidden" name="csrf" value="{{ csrf }}">
-        <input type="hidden" name="revision" value="{{ selected_revision }}">
-        <input type="hidden" name="kind" value="line-comment">
-        <input type="hidden" name="path-hex" value="{{ file.path_hex }}">
-        <div class="field">
-          <label for="review-side-{{ file.path_hex }}">Side</label>
-          <select id="review-side-{{ file.path_hex }}" name="side">
+          <details class="pull-request-line-comment">
+            <summary>Comment on a line</summary>
+            <form method="post" action="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/reviews">
+              <input type="hidden" name="csrf" value="{{ csrf }}">
+              <input type="hidden" name="revision" value="{{ selected_revision }}">
+              <input type="hidden" name="kind" value="line-comment">
+              <input type="hidden" name="path-hex" value="{{ file.path_hex }}">
+              <div class="pull-request-line-fields">
+                <div class="field">
+                  <label for="review-side-{{ file.path_hex }}">Side</label>
+                  <select id="review-side-{{ file.path_hex }}" name="side">
 {% if file.has_base %}
-            <option value="base">Base</option>
+                    <option value="base">Base</option>
 {% endif %}
 {% if file.has_head %}
-            <option value="head">Head</option>
+                    <option value="head">Head</option>
 {% endif %}
-          </select>
-        </div>
-        <div class="field">
-          <label for="review-line-{{ file.path_hex }}">Line</label>
-          <input id="review-line-{{ file.path_hex }}" name="line" type="number" min="1" required>
-          <p class="field-help">Enter a line number from the selected side in the diff above.</p>
-        </div>
-        <div class="field">
-          <label for="review-body-{{ file.path_hex }}">Line comment (Markdown)</label>
-          <textarea id="review-body-{{ file.path_hex }}" name="body" maxlength="262144" rows="4" required></textarea>
-        </div>
-        <button type="submit">Add line comment</button>
-      </form>
+                  </select>
+                </div>
+                <div class="field">
+                  <label for="review-line-{{ file.path_hex }}">Line</label>
+                  <input id="review-line-{{ file.path_hex }}" name="line" type="number" min="1" required>
+                </div>
+              </div>
+              <p class="field-help">Use a line number from the selected side in the diff above.</p>
+              <div class="field">
+                <label for="review-body-{{ file.path_hex }}">Comment (Markdown)</label>
+                <textarea id="review-body-{{ file.path_hex }}" name="body" maxlength="262144" rows="4" required></textarea>
+              </div>
+              <button type="submit">Add line comment</button>
+            </form>
+          </details>
 {% endif %}
 {% endif %}
-    </article>
+        </article>
 {% endfor %}
-  </section>
+      </div>
+    </section>
+
+    <section class="pull-request-section" aria-labelledby="discussion-heading">
+      <div class="pull-request-section-heading">
+        <div>
+          <p class="pull-request-kicker">Review activity</p>
+          <h2 id="discussion-heading">Discussion</h2>
+        </div>
+      </div>
+{% if reviews.is_empty() %}
+      <p class="pull-request-empty">No review comments yet.</p>
+{% else %}
+      <div class="pull-request-discussion">
+{% for review in reviews %}
+        <article class="comment-card" id="review-{{ review.id }}">
+          <header>
+            <strong>{{ review.author }}</strong>
+            <span>{{ review.kind|event_name }} · <time datetime="{{ review.created_at }}">{{ review.created_at|human_time }}</time></span>
+          </header>
+{% if review.kind == "line-comment" %}
+          <p class="pull-request-comment-anchor"><code title="{{ review.commit_object_id }}">{{ review.commit_object_id|short_id }}</code> · <code>{{ review.path }}</code> · {{ review.side }} line {{ review.line }}{% if review.outdated %} · <strong>Outdated</strong>{% endif %}</p>
+{% endif %}
+{% if review.has_body %}
+          <div class="markdown">{{ review.body_html|safe }}</div>
+{% endif %}
+        </article>
+{% endfor %}
+      </div>
+{% endif %}
+{% if reviews_has_previous || reviews_has_next %}
+      <nav class="pagination" aria-label="Review pages">
+{% if reviews_has_previous %}
+        <a href="?revision={{ selected_revision }}&amp;reviews_page={{ reviews_previous_page }}&amp;timeline_page={{ timeline_page }}">Newer reviews</a>
+{% endif %}
+{% if reviews_has_next %}
+        <a href="?revision={{ selected_revision }}&amp;reviews_page={{ reviews_next_page }}&amp;timeline_page={{ timeline_page }}">Older reviews</a>
+{% endif %}
+      </nav>
+{% endif %}
+    </section>
+
+    <div class="pull-request-decision-grid">
+{% if can_review %}
+      <section class="pull-request-section" aria-labelledby="review-heading">
+        <p class="pull-request-kicker">Your decision</p>
+        <h2 id="review-heading">Review revision {{ selected_revision }}</h2>
+        <form method="post" action="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/reviews">
+          <input type="hidden" name="csrf" value="{{ csrf }}">
+          <input type="hidden" name="revision" value="{{ selected_revision }}">
+          <input type="hidden" name="path-hex" value="">
+          <input type="hidden" name="side" value="">
+          <input type="hidden" name="line" value="">
+          <div class="field">
+            <label for="review-body">Review comment (Markdown)</label>
+            <textarea id="review-body" name="body" maxlength="262144" rows="6"></textarea>
+          </div>
+          <div class="pull-request-review-actions">
+            <button name="kind" value="comment" type="submit">Comment</button>
+            <button name="kind" value="approved" type="submit">Approve</button>
+            <button class="danger-action" name="kind" value="changes-requested" type="submit">Request changes</button>
+          </div>
+        </form>
+      </section>
+{% endif %}
 
 {% if can_merge %}
 {% if comparison.mergeability == "fast-forward" %}
-  <section aria-labelledby="merge-heading">
-    <h2 id="merge-heading">Merge this pull request</h2>
-    <form method="post" action="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/merge">
-      <input type="hidden" name="csrf" value="{{ csrf }}">
-      <input type="hidden" name="method" value="fast-forward">
-      <fieldset class="confirmation">
-        <legend>Merge this pull request?</legend>
-        <label><input type="radio" name="confirm" value="no" checked> No</label>
-        <label><input type="radio" name="confirm" value="yes"> Yes</label>
-      </fieldset>
-      <button type="submit">Fast-forward {{ pull_request.base_ref }}</button>
-    </form>
-  </section>
+      <section class="pull-request-section pull-request-merge" aria-labelledby="merge-heading">
+        <p class="pull-request-kicker">Ready to land</p>
+        <h2 id="merge-heading">Merge pull request</h2>
+        <p>This revision can be fast-forwarded into <code>{{ pull_request.base_ref }}</code>.</p>
+        <form method="post" action="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/merge">
+          <input type="hidden" name="csrf" value="{{ csrf }}">
+          <input type="hidden" name="method" value="fast-forward">
+          <fieldset class="confirmation">
+            <legend>Confirm merge</legend>
+            <label><input type="radio" name="confirm" value="no" checked> No</label>
+            <label><input type="radio" name="confirm" value="yes"> Yes</label>
+          </fieldset>
+          <button type="submit">Fast-forward merge</button>
+        </form>
+      </section>
 {% else if comparison.mergeability == "clean merge" %}
-  <section aria-labelledby="merge-heading">
-    <h2 id="merge-heading">Merge this pull request</h2>
-    <form method="post" action="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/merge">
-      <input type="hidden" name="csrf" value="{{ csrf }}">
-      <input type="hidden" name="method" value="merge-commit">
-      <fieldset class="confirmation">
-        <legend>Merge this pull request?</legend>
-        <label><input type="radio" name="confirm" value="no" checked> No</label>
-        <label><input type="radio" name="confirm" value="yes"> Yes</label>
-      </fieldset>
-      <button type="submit">Create a merge commit on {{ pull_request.base_ref }}</button>
-    </form>
-  </section>
+      <section class="pull-request-section pull-request-merge" aria-labelledby="merge-heading">
+        <p class="pull-request-kicker">Ready to land</p>
+        <h2 id="merge-heading">Merge pull request</h2>
+        <p>This revision can be merged into <code>{{ pull_request.base_ref }}</code> with a merge commit.</p>
+        <form method="post" action="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/merge">
+          <input type="hidden" name="csrf" value="{{ csrf }}">
+          <input type="hidden" name="method" value="merge-commit">
+          <fieldset class="confirmation">
+            <legend>Confirm merge</legend>
+            <label><input type="radio" name="confirm" value="no" checked> No</label>
+            <label><input type="radio" name="confirm" value="yes"> Yes</label>
+          </fieldset>
+          <button type="submit">Create merge commit</button>
+        </form>
+      </section>
 {% endif %}
 {% endif %}
+    </div>
 
-{% if can_review %}
-  <section aria-labelledby="review-heading">
-    <h2 id="review-heading">Review this revision</h2>
-    <form method="post" action="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/reviews">
-      <input type="hidden" name="csrf" value="{{ csrf }}">
-      <input type="hidden" name="revision" value="{{ selected_revision }}">
-      <input type="hidden" name="path-hex" value="">
-      <input type="hidden" name="side" value="">
-      <input type="hidden" name="line" value="">
-      <div class="field">
-        <label for="review-body">Review comment (Markdown)</label>
-        <textarea id="review-body" name="body" maxlength="262144" rows="8"></textarea>
+    <details class="pull-request-history">
+      <summary>History and Git details</summary>
+      <div class="pull-request-history-grid">
+        <section aria-labelledby="revisions-heading">
+          <h2 id="revisions-heading">Revisions</h2>
+          <ol>
+{% for revision in revisions %}
+            <li value="{{ revision.number }}">
+              <code title="{{ revision.head_object_id }}">{{ revision.head_object_id|short_id }}</code> against <code title="{{ revision.base_object_id }}">{{ revision.base_object_id|short_id }}</code>
+              <span>recorded by {{ revision.author }} <time datetime="{{ revision.created_at }}">{{ revision.created_at|human_time }}</time></span>
+              <a href="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/revisions/{{ revision.number }}.patch">Patch</a>
+{% if revision.number == selected_revision %}
+              <strong>Selected</strong>
+{% else %}
+              <a href="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}?revision={{ revision.number }}">Compare</a>
+{% endif %}
+            </li>
+{% endfor %}
+          </ol>
+        </section>
+
+        <section aria-labelledby="timeline-heading">
+          <h2 id="timeline-heading">Activity</h2>
+          <ol>
+{% for event in timeline %}
+            <li value="{{ event.sequence }}">{{ event.actor }} {{ event.kind|event_name }} <time datetime="{{ event.created_at }}">{{ event.created_at|human_time }}</time></li>
+{% endfor %}
+          </ol>
+{% if timeline_has_previous || timeline_has_next %}
+          <nav class="pagination" aria-label="Timeline pages">
+{% if timeline_has_previous %}
+            <a href="?revision={{ selected_revision }}&amp;reviews_page={{ reviews_page }}&amp;timeline_page={{ timeline_previous_page }}">Newer events</a>
+{% endif %}
+{% if timeline_has_next %}
+            <a href="?revision={{ selected_revision }}&amp;reviews_page={{ reviews_page }}&amp;timeline_page={{ timeline_next_page }}">Older events</a>
+{% endif %}
+          </nav>
+{% endif %}
+        </section>
       </div>
-      <button name="kind" value="comment" type="submit">Add comment</button>
-      <button name="kind" value="approved" type="submit">Approve</button>
-      <button name="kind" value="changes-requested" type="submit">Request changes</button>
-    </form>
-  </section>
-{% endif %}
-
+      <div class="pull-request-git-details">
+        <span>Fetch this pull request</span>
+        <code>git fetch origin refs/pull/{{ pull_request.number }}/head</code>
+      </div>
 {% if can_revise %}
-  <form method="post" action="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/revisions">
-    <input type="hidden" name="csrf" value="{{ csrf }}">
-    <button type="submit">Record current branch heads</button>
-  </form>
+      <form method="post" action="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/revisions">
+        <input type="hidden" name="csrf" value="{{ csrf }}">
+        <button type="submit">Record current branch heads</button>
+      </form>
 {% endif %}
+    </details>
+  </article>
 {% endblock %}

templates/pull_requests.html

Mode 100644100644; object 361fef6487fc2e2d673aab41

@@ -3,6 +3,7 @@
 {% block content %}
   <header class="repository-header">
     <h1><a href="/{{ owner }}">{{ owner }}</a>/<a href="/{{ owner }}/{{ repository }}">{{ repository }}</a></h1>
+{% let nav_section = "pulls" %}
 {% include "repository-nav.html" %}
   </header>
 

templates/recovery-code.html

Mode 100644100644; object b9eb1278be380b0216f8064c

@@ -4,4 +4,5 @@
   <h1>Save your recovery code</h1>
   <p>This code is shown one time. Store it offline. It replaces all SSH keys when you use it.</p>
   <pre><code>{{ recovery }}</code></pre>
+  <p class="page-actions"><a class="primary-action" href="/login">Continue to log in</a></p>
 {% endblock %}

templates/repository-nav.html

Mode 100644100644; object 3a5a38a01b14899528461b8e

@@ -1,12 +1,26 @@
-    <nav aria-label="Repository">
-      <a href="/{{ owner }}/{{ repository }}">Summary</a>
-      <a href="/{{ owner }}/{{ repository }}/refs">Refs</a>
-      <a href="/{{ owner }}/{{ repository }}/issues">Issues</a>
-      <a href="/{{ owner }}/{{ repository }}/pulls">Pull requests</a>
-      <a href="/{{ owner }}/{{ repository }}/watch">Watch</a>
-      <a href="/{{ owner }}/{{ repository }}/rss.xml">RSS</a>
-      <a href="/{{ owner }}/{{ repository }}/search">Search</a>
-{% if signed_in %}
-      <a href="/{{ owner }}/{{ repository }}/settings">Settings</a>
+    <nav class="repository-navigation" aria-label="Repository">
+      <div class="repository-tabs">
+        <a href="/{{ owner }}/{{ repository }}"{% if nav_section == "summary" %} aria-current="page"{% endif %}>Summary</a>
+        <a href="/{{ owner }}/{{ repository }}/issues"{% if nav_section == "issues" %} aria-current="page"{% endif %}>Issues</a>
+        <a href="/{{ owner }}/{{ repository }}/pulls"{% if nav_section == "pulls" %} aria-current="page"{% endif %}>Pull requests</a>
+      </div>
+      <div class="repository-tools">
+        <a class="icon-link" href="/{{ owner }}/{{ repository }}/refs" aria-label="Refs" title="Refs"{% if nav_section == "refs" %} aria-current="page"{% endif %}>
+          <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="6" cy="5" r="2"></circle><circle cx="6" cy="19" r="2"></circle><circle cx="18" cy="12" r="2"></circle><path d="M6 7v10M8 5h3a7 7 0 0 1 7 7"></path></svg>
+        </a>
+        <a class="icon-link" href="/{{ owner }}/{{ repository }}/watch" aria-label="Watch" title="Watch"{% if nav_section == "watch" %} aria-current="page"{% endif %}>
+          <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"></path><circle cx="12" cy="12" r="2.5"></circle></svg>
+        </a>
+        <a class="icon-link" href="/{{ owner }}/{{ repository }}/rss.xml" aria-label="RSS feed" title="RSS feed">
+          <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="5" cy="19" r="1"></circle><path d="M4 11a9 9 0 0 1 9 9M4 5a15 15 0 0 1 15 15"></path></svg>
+        </a>
+        <a class="icon-link" href="/{{ owner }}/{{ repository }}/search" aria-label="Search repository" title="Search repository"{% if nav_section == "search" %} aria-current="page"{% endif %}>
+          <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="6"></circle><path d="m16 16 4 4"></path></svg>
+        </a>
+{% if can_manage %}
+        <a class="icon-link" href="/{{ owner }}/{{ repository }}/settings" aria-label="Repository settings" title="Repository settings"{% if nav_section == "settings" %} aria-current="page"{% endif %}>
+          <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6 1.7 1.7 0 0 0 10 3V2.8h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z"></path></svg>
+        </a>
 {% endif %}
+      </div>
     </nav>

templates/repository-settings.html

Mode 100644100644; object 6d1d5a228e69ece938beea62

@@ -3,6 +3,7 @@
 {% block content %}
   <header class="repository-header">
     <h1><a href="/{{ owner }}">{{ owner }}</a>/<a href="/{{ owner }}/{{ repository }}">{{ repository }}</a></h1>
+{% let nav_section = "settings" %}
 {% include "repository-nav.html" %}
   </header>
 
@@ -56,7 +57,7 @@
             <input type="hidden" name="csrf" value="{{ csrf }}">
             <input type="hidden" name="username" value="{{ collaborator.username }}">
             <input type="hidden" name="role" value="{{ collaborator.role }}">
-            <button type="submit" name="action" value="remove">Remove</button>
+            <button class="danger-action" type="submit" name="action" value="remove">Remove</button>
           </form>
         </li>
 {% endfor %}
@@ -104,7 +105,7 @@
         <label><input type="radio" name="confirm" value="no" checked> No</label>
         <label><input type="radio" name="confirm" value="yes"> Yes</label>
       </fieldset>
-      <button type="submit">Archive repository</button>
+      <button class="danger-action" type="submit">Archive repository</button>
     </form>
   </section>
 {% endblock %}

templates/repository.html

Mode 100644100644; object 490e30a01b57dea4aac39f67

@@ -3,20 +3,40 @@
 {% block content %}
   <header class="repository-header">
     <h1><a href="/{{ owner }}">{{ owner }}</a>/<a href="/{{ owner }}/{{ repository }}">{{ repository }}</a></h1>
+{% let nav_section = *page_kind %}
 {% include "repository-nav.html" %}
   </header>
 
 {% if has_head && page_kind != "summary" %}
-  <p><a href="/{{ owner }}/{{ repository }}/tree/{{ commit_id }}">Browse tree</a> · <a href="/{{ owner }}/{{ repository }}/commit/{{ commit_id }}">Show commit</a> · <a href="/{{ owner }}/{{ repository }}/archive/{{ commit_id }}.tar">Download archive</a></p>
+  <nav class="repository-toolbar" aria-label="Repository revision">
+    <div class="repository-context">
+      <a href="/{{ owner }}/{{ repository }}/commit/{{ commit_id }}" aria-label="Commit {{ commit_id }}"><code title="{{ commit_id }}">{{ commit_id|short_id }}</code></a>
+    </div>
+    <div class="repository-actions">
+{% if page_kind != "tree" %}
+      <a class="icon-link" href="/{{ owner }}/{{ repository }}/tree/{{ commit_id }}" aria-label="Browse tree" title="Browse tree">
+        <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 6.5h7l2 2h9v10H3z"></path></svg>
+      </a>
+{% endif %}
+      <a class="icon-link" href="/{{ owner }}/{{ repository }}/archive/{{ commit_id }}.tar" aria-label="Download archive" title="Download archive">
+        <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12m-4-4 4 4 4-4M4 20h16"></path></svg>
+      </a>
+    </div>
+  </nav>
 {% endif %}
 
 {% if page_kind == "summary" %}
 {% if has_head %}
   <nav class="repository-toolbar" aria-label="Repository files">
-    <strong>{{ default_branch }}</strong>
-    <a href="/{{ owner }}/{{ repository }}/tree/{{ commit_id }}">Browse files</a>
-    <a href="/{{ owner }}/{{ repository }}/commit/{{ commit_id }}"><code title="{{ commit_id }}">{{ commit_id|short_id }}</code></a>
-    <a href="/{{ owner }}/{{ repository }}/archive/{{ commit_id }}.tar">Download archive</a>
+    <div class="repository-context">
+      <strong>{{ default_branch }}</strong>
+      <a href="/{{ owner }}/{{ repository }}/commit/{{ commit_id }}" aria-label="Commit {{ commit_id }}"><code title="{{ commit_id }}">{{ commit_id|short_id }}</code></a>
+    </div>
+    <div class="repository-actions">
+      <a class="icon-link" href="/{{ owner }}/{{ repository }}/archive/{{ commit_id }}.tar" aria-label="Download archive" title="Download archive">
+        <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12m-4-4 4 4 4-4M4 20h16"></path></svg>
+      </a>
+    </div>
   </nav>
 {% endif %}
   <div class="repository-summary-layout">
@@ -28,7 +48,7 @@
       <tbody>
 {% for item in entries %}
         <tr>
-          <th scope="row"><a href="/{{ owner }}/{{ repository }}/{{ item.href }}">{{ item.name }}</a></th>
+          <th scope="row"><a href="/{{ owner }}/{{ repository }}/{{ item.href }}">{{ item.name }}{% if item.kind == "tree" %}/{% endif %}</a></th>
           <td>{{ item.kind }}</td>
           <td><code title="{{ item.id }}">{{ item.id|short_id }}</code></td>
         </tr>
@@ -41,6 +61,16 @@
   </section>
 {% else %}
   <p>This repository has no commits.</p>
+{% endif %}
+{% if has_readme %}
+  <section class="repository-panel repository-readme" aria-labelledby="readme-heading">
+    <h2 id="readme-heading">{{ readme_path }}</h2>
+{% if readme_binary %}
+    <p>Binary README content cannot be shown.</p>
+{% else %}
+    <div class="markdown">{{ readme_html|safe }}</div>
+{% endif %}
+  </section>
 {% endif %}
   </div>
   <aside class="repository-sidebar" aria-label="Repository information">
@@ -90,16 +120,6 @@
   </section>
   </aside>
   </div>
-{% if has_readme %}
-  <section class="repository-panel repository-readme" aria-labelledby="readme-heading">
-    <h2 id="readme-heading">{{ readme_path }}</h2>
-{% if readme_binary %}
-    <p>Binary README content cannot be shown.</p>
-{% else %}
-    <div class="markdown">{{ readme_html|safe }}</div>
-{% endif %}
-  </section>
-{% endif %}
 {% endif %}
 
 {% if page_kind == "commits" %}
@@ -149,7 +169,7 @@
 {% for item in entries %}
       <tr>
         <td><code>{{ item.mode }}</code></td><td>{{ item.kind }}</td>
-        <th scope="row"><a href="/{{ owner }}/{{ repository }}/{{ item.href }}">{{ item.name }}</a></th>
+        <th scope="row"><a href="/{{ owner }}/{{ repository }}/{{ item.href }}">{{ item.name }}{% if item.kind == "tree" %}/{% endif %}</a></th>
         <td><code title="{{ item.id }}">{{ item.id|short_id }}</code></td>
       </tr>
 {% endfor %}

templates/watch.html

Mode 100644100644; object 172c27e5483c610ffb2e4440

@@ -3,6 +3,7 @@
 {% block content %}
   <header class="repository-header">
     <h1><a href="/{{ owner }}">{{ owner }}</a>/<a href="/{{ owner }}/{{ repository }}">{{ repository }}</a></h1>
+{% let nav_section = "watch" %}
 {% include "repository-nav.html" %}
   </header>
 

tests/git_reads.rs

Mode 100644100644; object b08b72e0ca2be79c2d033d00

@@ -75,18 +75,25 @@
                 .map(|entry| entry.name.as_slice())
                 .collect::<Vec<_>>(),
             [
+                b"docs".as_slice(),
+                b"src".as_slice(),
                 b"README.md".as_slice(),
                 b"binary".as_slice(),
-                b"docs".as_slice(),
                 b"mode.sh".as_slice(),
-                b"renamed.txt".as_slice(),
-                b"src".as_slice()
+                b"renamed.txt".as_slice()
             ]
         );
         let (root_prefix, truncated) = service
             .tree_prefix(fixture.second, b"", 2, &cancellation)
             .expect("read a root-tree prefix");
         assert_eq!(root_prefix.len(), 2);
+        assert_eq!(
+            root_prefix
+                .iter()
+                .map(|entry| entry.name.as_slice())
+                .collect::<Vec<_>>(),
+            [b"docs".as_slice(), b"src".as_slice()]
+        );
         assert!(truncated);
         let metadata = service
             .commit_metadata(&[fixture.first, fixture.second], &cancellation)

tests/repository_policy.rs

Mode 100644100644; object 2defcbea37b2c4ddedc399b3

@@ -81,6 +81,21 @@
     for operation in [RepositoryOperation::Write, RepositoryOperation::Maintain] {
         assert_denied(&policy, Some("reader"), operation);
     }
+    assert!(
+        store
+            .repository_can_maintain("owner", "project", "owner")
+            .expect("read owner permission")
+    );
+    assert!(
+        store
+            .repository_can_maintain("owner", "project", "maintainer")
+            .expect("read maintainer permission")
+    );
+    assert!(
+        !store
+            .repository_can_maintain("owner", "project", "writer")
+            .expect("read writer permission")
+    );
     for actor in [None, Some("stranger"), Some("suspended"), Some("missing")] {
         for operation in operations() {
             assert_denied(&policy, actor, operation);

tests/snapshots/web/home.html

Mode 100644100644; object ea15f172436620512661677a

@@ -4,38 +4,66 @@
   <meta charset="utf-8">
   <meta name="viewport" content="width=device-width, initial-scale=1">
   <title>Repositories · tit</title>
-  <link rel="stylesheet" href="/assets/style.css?v=2">
+  <link rel="stylesheet" href="/assets/style.css?v=10">
 </head>
 <body>
   <a class="skip-link" href="#main">Skip to content</a>
+  <div class="background-icon" aria-hidden="true">
+    <svg viewBox="0 0 24 24">
+      <circle cx="6" cy="5" r="2"/>
+      <circle cx="6" cy="19" r="2"/>
+      <circle cx="18" cy="12" r="2"/>
+      <path d="M6 7v10"/>
+      <path d="M8 7c0 3 2 5 8 5"/>
+    </svg>
+  </div>
   <header class="site-header">
-    <a class="brand" href="/" aria-label="tit home">tit</a>
+    <a class="brand" href="/" aria-label="tit home">
+      <svg class="brand-icon" viewBox="0 0 24 24" aria-hidden="true">
+        <circle cx="6" cy="5" r="2"/>
+        <circle cx="6" cy="19" r="2"/>
+        <circle cx="18" cy="12" r="2"/>
+        <path d="M6 7v10"/>
+        <path d="M8 7c0 3 2 5 8 5"/>
+      </svg>
+      tit
+    </a>
     <nav aria-label="Primary">
-      <a href="/">Home</a>
-      <a href="/search">Search</a>
+      <a class="icon-link" href="/" aria-label="Home" title="Home">
+        <svg viewBox="0 0 24 24" aria-hidden="true">
+          <path d="M3 11.5 12 4l9 7.5"/>
+          <path d="M5.5 10.5V20h13v-9.5"/>
+          <path d="M9.5 20v-6h5v6"/>
+        </svg>
+      </a>
+      <a class="icon-link" href="/search" aria-label="Search" title="Search">
+        <svg viewBox="0 0 24 24" aria-hidden="true">
+          <circle cx="11" cy="11" r="7"/>
+          <path d="m20 20-4-4"/>
+        </svg>
+      </a>
 
-      <a href="/signup">Create account</a>
-      <a href="/recover">Recover account</a>
-      <a href="/login">Log in</a>
+      <a class="icon-link" href="/account" aria-label="Account" title="Account">
+        <svg viewBox="0 0 24 24" aria-hidden="true">
+          <circle cx="12" cy="8" r="4"/>
+          <path d="M4 21a8 8 0 0 1 16 0"/>
+        </svg>
+      </a>
 
     </nav>
   </header>
+  <hr class="site-header-rule">
   <main id="main">
 
-  <div class="two-column">
+  <div class="two-column home-grid">
 
   <section class="home-intro">
     <h1>A small Git CDE</h1>
     <p>tit provides self-hosted Git repositories, issues, pull requests, and code review in one server.</p>
-    <nav class="home-actions" aria-label="Get started">
-      <a href="/signup">Create account</a>
-      <a href="/login">Log in</a>
-      <a href="/search">Search repositories</a>
-    </nav>
   </section>
 
   <section>
-    <h2>Recently updated public repositories</h2>
+    <h2>Recently updated</h2>
 
 
     <p>No repositories are available.</p>
@@ -65,6 +93,7 @@
 
   </main>
   <footer>
+    <small>tit · open source · MIT licensed</small>
     <small>Request <code><request-id></code></small>
   </footer>
 </body>

tests/snapshots/web/method-not-allowed.html

Mode 100644100644; object a72ec73ee6b3c45062f44849

@@ -4,22 +4,55 @@
   <meta charset="utf-8">
   <meta name="viewport" content="width=device-width, initial-scale=1">
   <title>Method not allowed · tit</title>
-  <link rel="stylesheet" href="/assets/style.css?v=2">
+  <link rel="stylesheet" href="/assets/style.css?v=10">
 </head>
 <body>
   <a class="skip-link" href="#main">Skip to content</a>
+  <div class="background-icon" aria-hidden="true">
+    <svg viewBox="0 0 24 24">
+      <circle cx="6" cy="5" r="2"/>
+      <circle cx="6" cy="19" r="2"/>
+      <circle cx="18" cy="12" r="2"/>
+      <path d="M6 7v10"/>
+      <path d="M8 7c0 3 2 5 8 5"/>
+    </svg>
+  </div>
   <header class="site-header">
-    <a class="brand" href="/" aria-label="tit home">tit</a>
+    <a class="brand" href="/" aria-label="tit home">
+      <svg class="brand-icon" viewBox="0 0 24 24" aria-hidden="true">
+        <circle cx="6" cy="5" r="2"/>
+        <circle cx="6" cy="19" r="2"/>
+        <circle cx="18" cy="12" r="2"/>
+        <path d="M6 7v10"/>
+        <path d="M8 7c0 3 2 5 8 5"/>
+      </svg>
+      tit
+    </a>
     <nav aria-label="Primary">
-      <a href="/">Home</a>
-      <a href="/search">Search</a>
+      <a class="icon-link" href="/" aria-label="Home" title="Home">
+        <svg viewBox="0 0 24 24" aria-hidden="true">
+          <path d="M3 11.5 12 4l9 7.5"/>
+          <path d="M5.5 10.5V20h13v-9.5"/>
+          <path d="M9.5 20v-6h5v6"/>
+        </svg>
+      </a>
+      <a class="icon-link" href="/search" aria-label="Search" title="Search">
+        <svg viewBox="0 0 24 24" aria-hidden="true">
+          <circle cx="11" cy="11" r="7"/>
+          <path d="m20 20-4-4"/>
+        </svg>
+      </a>
 
-      <a href="/signup">Create account</a>
-      <a href="/recover">Recover account</a>
-      <a href="/login">Log in</a>
+      <a class="icon-link" href="/account" aria-label="Account" title="Account">
+        <svg viewBox="0 0 24 24" aria-hidden="true">
+          <circle cx="12" cy="8" r="4"/>
+          <path d="M4 21a8 8 0 0 1 16 0"/>
+        </svg>
+      </a>
 
     </nav>
   </header>
+  <hr class="site-header-rule">
   <main id="main">
 
   <h1>Method not allowed</h1>
@@ -28,6 +61,7 @@
 
   </main>
   <footer>
+    <small>tit · open source · MIT licensed</small>
     <small>Request <code><request-id></code></small>
   </footer>
 </body>

tests/snapshots/web/not-found.html

Mode 100644100644; object 99463ba8d462fca64b2d6ac4

@@ -4,22 +4,55 @@
   <meta charset="utf-8">
   <meta name="viewport" content="width=device-width, initial-scale=1">
   <title>Page not found · tit</title>
-  <link rel="stylesheet" href="/assets/style.css?v=2">
+  <link rel="stylesheet" href="/assets/style.css?v=10">
 </head>
 <body>
   <a class="skip-link" href="#main">Skip to content</a>
+  <div class="background-icon" aria-hidden="true">
+    <svg viewBox="0 0 24 24">
+      <circle cx="6" cy="5" r="2"/>
+      <circle cx="6" cy="19" r="2"/>
+      <circle cx="18" cy="12" r="2"/>
+      <path d="M6 7v10"/>
+      <path d="M8 7c0 3 2 5 8 5"/>
+    </svg>
+  </div>
   <header class="site-header">
-    <a class="brand" href="/" aria-label="tit home">tit</a>
+    <a class="brand" href="/" aria-label="tit home">
+      <svg class="brand-icon" viewBox="0 0 24 24" aria-hidden="true">
+        <circle cx="6" cy="5" r="2"/>
+        <circle cx="6" cy="19" r="2"/>
+        <circle cx="18" cy="12" r="2"/>
+        <path d="M6 7v10"/>
+        <path d="M8 7c0 3 2 5 8 5"/>
+      </svg>
+      tit
+    </a>
     <nav aria-label="Primary">
-      <a href="/">Home</a>
-      <a href="/search">Search</a>
+      <a class="icon-link" href="/" aria-label="Home" title="Home">
+        <svg viewBox="0 0 24 24" aria-hidden="true">
+          <path d="M3 11.5 12 4l9 7.5"/>
+          <path d="M5.5 10.5V20h13v-9.5"/>
+          <path d="M9.5 20v-6h5v6"/>
+        </svg>
+      </a>
+      <a class="icon-link" href="/search" aria-label="Search" title="Search">
+        <svg viewBox="0 0 24 24" aria-hidden="true">
+          <circle cx="11" cy="11" r="7"/>
+          <path d="m20 20-4-4"/>
+        </svg>
+      </a>
 
-      <a href="/signup">Create account</a>
-      <a href="/recover">Recover account</a>
-      <a href="/login">Log in</a>
+      <a class="icon-link" href="/account" aria-label="Account" title="Account">
+        <svg viewBox="0 0 24 24" aria-hidden="true">
+          <circle cx="12" cy="8" r="4"/>
+          <path d="M4 21a8 8 0 0 1 16 0"/>
+        </svg>
+      </a>
 
     </nav>
   </header>
+  <hr class="site-header-rule">
   <main id="main">
 
   <h1>Page not found</h1>
@@ -28,6 +61,7 @@
 
   </main>
   <footer>
+    <small>tit · open source · MIT licensed</small>
     <small>Request <code><request-id></code></small>
   </footer>
 </body>

tests/web_shell.rs

Mode 100644100644; object 2bdafb662ad2f2795d5f7a3e

@@ -19,6 +19,7 @@
     assert_eq!(home.header("content-type"), "text/html; charset=utf-8");
     assert_eq!(home.header("cache-control"), "no-store");
     assert!(home.body.contains("<header class=\"site-header\">"));
+    assert!(home.body.contains("<hr class=\"site-header-rule\">"));
     assert!(home.body.contains("<nav aria-label=\"Primary\">"));
     assert!(home.body.contains("<main id=\"main\">"));
     assert!(home.body.contains("<footer>"));
@@ -27,6 +28,18 @@
     assert!(!home.body.to_ascii_lowercase().contains("<script"));
     assert_security_policy(&home);
     assert_snapshot(&home, include_str!("snapshots/web/home.html"));
+
+    let account = request(server.address(), "GET", "/account", &[]).await;
+    assert_eq!(account.status, 200);
+    assert!(
+        account
+            .body
+            .contains("<h1 id=\"account-heading\">Account</h1>")
+    );
+    assert!(account.body.contains("href=\"/login\""));
+    assert!(account.body.contains("href=\"/signup\""));
+    assert!(account.body.contains("href=\"/recover\""));
+    assert_security_policy(&account);
 
     let request_id = home.header("x-request-id");
     assert_request_id(request_id);
@@ -53,8 +66,12 @@
     assert_eq!(css.header("content-type"), "text/css; charset=utf-8");
     assert_eq!(css.header("cache-control"), "no-cache");
     assert_eq!(css.body, include_str!("../assets/style.css"));
+    assert!(css.body.contains("font-family: \"Space Mono\""));
+    assert!(css.body.contains(".background-icon"));
     assert!(css.body.contains("@media (max-width: 44rem)"));
     assert!(css.body.contains(".two-column"));
+    assert!(css.body.contains(".home-grid > section"));
+    assert!(css.body.contains(".repository-navigation"));
     assert_security_policy(&css);
 
     let css_head = request(server.address(), "HEAD", "/assets/style.css", &[]).await;
@@ -64,6 +81,34 @@
         css_head.header("content-length"),
         css.body.len().to_string()
     );
+
+    let regular_font = request(
+        server.address(),
+        "HEAD",
+        "/assets/SpaceMono-Regular.ttf",
+        &[],
+    )
+    .await;
+    assert_eq!(regular_font.status, 200);
+    assert_eq!(regular_font.header("content-type"), "font/ttf");
+    assert_eq!(
+        regular_font.header("content-length"),
+        include_bytes!("../assets/SpaceMono-Regular.ttf")
+            .len()
+            .to_string()
+    );
+    assert_security_policy(&regular_font);
+
+    let bold_font = request(server.address(), "HEAD", "/assets/SpaceMono-Bold.ttf", &[]).await;
+    assert_eq!(bold_font.status, 200);
+    assert_eq!(bold_font.header("content-type"), "font/ttf");
+    assert_eq!(
+        bold_font.header("content-length"),
+        include_bytes!("../assets/SpaceMono-Bold.ttf")
+            .len()
+            .to_string()
+    );
+    assert_security_policy(&bold_font);
 
     let signup = request(server.address(), "GET", "/signup", &[]).await;
     assert_eq!(signup.status, 200);
@@ -287,7 +332,7 @@
 fn assert_security_policy(response: &HttpResponse) {
     assert_eq!(
         response.header("content-security-policy"),
-        "default-src 'none'; style-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"
+        "default-src 'none'; style-src 'self'; font-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"
     );
     assert_eq!(response.header("x-content-type-options"), "nosniff");
     assert_eq!(response.header("x-frame-options"), "DENY");