Step 6: port Sarah LiveViews and basecoat.

1905e683088b · AtlantisPleb · · parent 02d1214475fd

Step 6: port Sarah LiveViews and basecoat.

- Copies all remaining live surfaces from Sarah and re-namespaces them.

- Adds the Sarah-style command bar and account control to Layouts.

- Vendors assets/vendor/basecoat/ and imports only base/base.css from app.css.

- Wires routes for /status, /changelog, /leaderboard, /code/*, /computers, /admin, /admin/forge.

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified assets/css/app.css
  • added assets/vendor/basecoat/LICENSE.md
  • added assets/vendor/basecoat/README.md
  • added assets/vendor/basecoat/base/base.css
  • added assets/vendor/basecoat/components/accordion.css
  • added assets/vendor/basecoat/components/alert.css
  • added assets/vendor/basecoat/components/avatar.css
  • added assets/vendor/basecoat/components/badge.css
  • added assets/vendor/basecoat/components/breadcrumb.css
  • added assets/vendor/basecoat/components/button-group.css
  • added assets/vendor/basecoat/components/button.css
  • added assets/vendor/basecoat/components/card.css
  • added assets/vendor/basecoat/components/chart.css
  • added assets/vendor/basecoat/components/checkbox.css
  • added assets/vendor/basecoat/components/collapsible.css
  • added assets/vendor/basecoat/components/combobox.css
  • added assets/vendor/basecoat/components/command.css
  • added assets/vendor/basecoat/components/dialog.css
  • added assets/vendor/basecoat/components/drawer.css
  • added assets/vendor/basecoat/components/dropdown-menu.css
  • added assets/vendor/basecoat/components/empty.css
  • added assets/vendor/basecoat/components/field.css
  • added assets/vendor/basecoat/components/form.css
  • added assets/vendor/basecoat/components/input-group.css
  • added assets/vendor/basecoat/components/input.css
  • added assets/vendor/basecoat/components/item.css
  • added assets/vendor/basecoat/components/kbd.css
  • added assets/vendor/basecoat/components/label.css
  • added assets/vendor/basecoat/components/native-select.css
  • added assets/vendor/basecoat/components/popover.css
  • added assets/vendor/basecoat/components/progress.css
  • added assets/vendor/basecoat/components/radio.css
  • added assets/vendor/basecoat/components/range.css
  • added assets/vendor/basecoat/components/scrollbar.css
  • added assets/vendor/basecoat/components/select.css
  • added assets/vendor/basecoat/components/sidebar.css
  • added assets/vendor/basecoat/components/skeleton.css
  • added assets/vendor/basecoat/components/switch.css
  • added assets/vendor/basecoat/components/table.css
  • added assets/vendor/basecoat/components/tabs.css
  • added assets/vendor/basecoat/components/textarea.css
  • added assets/vendor/basecoat/components/toast.css
  • added assets/vendor/basecoat/components/tooltip.css
  • modified lib/openagents_web/components/layouts.ex
  • added lib/openagents_web/live/admin_forge_live.ex
  • added lib/openagents_web/live/admin_live.ex
  • added lib/openagents_web/live/changelog_live.ex
  • added lib/openagents_web/live/code_blob_live.ex
  • added lib/openagents_web/live/code_commit_live.ex
  • added lib/openagents_web/live/code_repo_live.ex
  • added lib/openagents_web/live/computers_live.ex
  • added lib/openagents_web/live/leaderboard_live.ex
  • added lib/openagents_web/live/network_status_live.ex
  • added lib/openagents_web/live/ui_gallery_live.ex
  • added lib/openagents_web/live/voice_spike_live.ex
  • added lib/openagents_web/public_not_found_error.ex
  • modified lib/openagents_web/router.ex

Diff

57 files changed, +4553 -32

assets/css/app.css modified +1

@@ -3,6 +3,7 @@

3 3
4 4
@import "tailwindcss" source(none);
5 5
@import "phoenix-colocated/openagents/colocated.css";
6
@import "../vendor/basecoat/base/base.css";
6 7
@import "./sarah.css";
7 8
@source "../css";
8 9
@source "../js";
assets/vendor/basecoat/LICENSE.md added +21

@@ -0,0 +1,21 @@

1
MIT License
2
3
Copyright (c) 2025 Ronan Berder
4
5
Permission is hereby granted, free of charge, to any person obtaining a copy
6
of this software and associated documentation files (the "Software"), to deal
7
in the Software without restriction, including without limitation the rights
8
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
copies of the Software, and to permit persons to whom the Software is
10
furnished to do so, subject to the following conditions:
11
12
The above copyright notice and this permission notice shall be included in all
13
copies or substantial portions of the Software.
14
15
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
SOFTWARE.
\ No newline at end of file
assets/vendor/basecoat/README.md added +51

@@ -0,0 +1,51 @@

1
# Basecoat (vendored)
2
3
Third-party source. **Do not edit these files.**
4
5
- Upstream: [hunvreus/basecoat](https://github.com/hunvreus/basecoat)
6
- Pinned tag: `1.0.2`
7
- Commit: `6953a4a`
8
- License: MIT, Copyright (c) 2025 Ronan Berder — see `LICENSE.md`
9
- Vendored: 2026-08-17
10
11
## What is here, and what is not
12
13
Copied from upstream `src/css/`:
14
15
- `base/base.css` — shadcn-compatible token contract and `@theme` mapping
16
- `components/*.css` — component structure: layout, accessibility selectors, and
17
  behavior hooks, carrying no color and no radius
18
19
Deliberately **not** vendored:
20
21
- `src/css/styles/*` — the Vega/Nova/Maia/Lyra/Mira/Luma/Sera/Rhea visual packs.
22
  Sarah writes its own pack at `assets/css/style-sarah.css`. Importing one of
23
  theirs and overriding it would leave dead visual rules in the bundle and make
24
  Sarah's identity a diff against another product's defaults.
25
- `src/js/*` — no Basecoat JavaScript is loaded. The only component Sarah's
26
  surfaces would need it for is `dropdown-menu`, and the account menu uses the
27
  native `popover` API instead, which is required to work without custom
28
  client-side JavaScript. Taking zero Basecoat JS also removes the LiveView
29
  DOM-patching risk class: no `MutationObserver`, no `basecoat.initAll()` on
30
  reconnect, no cached-DOM rehydration.
31
32
## Import rule
33
34
`assets/css/app.css` imports `base/base.css` plus **individually named**
35
component files. Component CSS lives in `@layer components` and is emitted
36
whether or not the class appears in markup, so importing the full set costs
37
about 13.5 KB gzip against about 7.4 KB for the components Sarah actually uses.
38
Never import `basecoat.css`, `basecoat-base.css`, or `basecoat-components.css`.
39
40
Adding an import is a deliberate act tied to a surface that needs it.
41
42
## Upgrading
43
44
1. Re-copy `src/css/base` and `src/css/components` from the new upstream tag.
45
2. Update the tag, commit, and date above.
46
3. Rebuild and check the component gallery at `/dev/ui` (dev only) against
47
   `DESIGN.md` — the shared corner radius, the sanctioned depth tokens, and the
48
   reserved semantic colors.
49
4. Run `mix precommit`, which includes the CSS contract guard.
50
51
See `docs/decisions/0003-basecoat-component-library.md`.
assets/vendor/basecoat/base/base.css added +136

@@ -0,0 +1,136 @@

1
@custom-variant dark (&:is(html.dark *));
2
3
:root {
4
  --radius: 0.625rem;
5
  --background: oklch(1 0 0);
6
  --foreground: oklch(0.145 0 0);
7
  --card: oklch(1 0 0);
8
  --card-foreground: oklch(0.145 0 0);
9
  --popover: oklch(1 0 0);
10
  --popover-foreground: oklch(0.145 0 0);
11
  --primary: oklch(0.205 0 0);
12
  --primary-foreground: oklch(0.985 0 0);
13
  --secondary: oklch(0.97 0 0);
14
  --secondary-foreground: oklch(0.205 0 0);
15
  --muted: oklch(0.97 0 0);
16
  --muted-foreground: oklch(0.556 0 0);
17
  --accent: oklch(0.97 0 0);
18
  --accent-foreground: oklch(0.205 0 0);
19
  --destructive: oklch(0.577 0.245 27.325);
20
  --border: oklch(0.922 0 0);
21
  --input: oklch(0.922 0 0);
22
  --ring: oklch(0.708 0 0);
23
  --chart-1: #93c5fd;
24
  --chart-2: #3b82f6;
25
  --chart-3: #2563eb;
26
  --chart-4: #1d4ed8;
27
  --chart-5: #1e40af;
28
  --sidebar: oklch(0.985 0 0);
29
  --sidebar-foreground: oklch(0.145 0 0);
30
  --sidebar-primary: oklch(0.205 0 0);
31
  --sidebar-primary-foreground: oklch(0.985 0 0);
32
  --sidebar-accent: oklch(0.97 0 0);
33
  --sidebar-accent-foreground: oklch(0.205 0 0);
34
  --sidebar-border: oklch(0.922 0 0);
35
  --sidebar-ring: oklch(0.708 0 0);
36
  --sidebar-width: 16rem;
37
  --sidebar-mobile-width: 18rem;
38
  --scrollbar-track: transparent;
39
  --scrollbar-thumb: var(--border);
40
  --scrollbar-width: 0.625rem;
41
  --scrollbar-sm-width: 0.375rem;
42
  --scrollbar-radius: 9999px;
43
  --chevron-down-icon: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="oklch(0.556 0 0)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down-icon lucide-chevron-down"><path d="m6 9 6 6 6-6"/></svg>'); /* --muted-foreground */
44
  --chevron-down-icon-50: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="oklch(0.556 0 0 / 0.5)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down-icon lucide-chevron-down"><path d="m6 9 6 6 6-6"/></svg>'); /* --muted-foreground + 50% opacity */
45
  --check-icon: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="oklch(0.556 0 0)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check-icon lucide-check"><path d="M20 6 9 17l-5-5"/></svg>'); /* --muted-foreground */
46
}
47
48
.dark {
49
  --background: oklch(0.145 0 0);
50
  --foreground: oklch(0.985 0 0);
51
  --card: oklch(0.205 0 0);
52
  --card-foreground: oklch(0.985 0 0);
53
  --popover: oklch(0.205 0 0);
54
  --popover-foreground: oklch(0.985 0 0);
55
  --primary: oklch(0.922 0 0);
56
  --primary-foreground: oklch(0.205 0 0);
57
  --secondary: oklch(0.269 0 0);
58
  --secondary-foreground: oklch(0.985 0 0);
59
  --muted: oklch(0.269 0 0);
60
  --muted-foreground: oklch(0.708 0 0);
61
  --accent: oklch(0.371 0 0);
62
  --accent-foreground: oklch(0.985 0 0);
63
  --destructive: oklch(0.704 0.191 22.216);
64
  --border: oklch(1 0 0 / 10%);
65
  --input: oklch(1 0 0 / 15%);
66
  --ring: oklch(0.556 0 0);
67
  --chart-1: #93c5fd;
68
  --chart-2: #3b82f6;
69
  --chart-3: #2563eb;
70
  --chart-4: #1d4ed8;
71
  --chart-5: #1e40af;
72
  --sidebar: oklch(0.205 0 0);
73
  --sidebar-foreground: oklch(0.985 0 0);
74
  --sidebar-primary: oklch(0.488 0.243 264.376);
75
  --sidebar-primary-foreground: oklch(0.985 0 0);
76
  --sidebar-accent: oklch(0.269 0 0);
77
  --sidebar-accent-foreground: oklch(0.985 0 0);
78
  --sidebar-border: oklch(1 0 0 / 10%);
79
  --sidebar-ring: oklch(0.439 0 0);
80
  --chevron-down-icon: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="oklch(0.708 0 0)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down-icon lucide-chevron-down"><path d="m6 9 6 6 6-6"/></svg>'); /* --muted-foreground */
81
  --chevron-down-icon-50: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="oklch(0.708 0 0 / 0.5)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down-icon lucide-chevron-down"><path d="m6 9 6 6 6-6"/></svg>'); /* --muted-foreground + 50% opacity */
82
  --check-icon: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="oklch(0.708 0 0)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check-icon lucide-check"><path d="M20 6 9 17l-5-5"/></svg>');/* --muted-foreground */
83
  color-scheme: dark;
84
}
85
86
@theme {
87
  --font-sans: "Geist Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
88
  --font-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
89
  --radius-sm: calc(var(--radius) - 4px);
90
  --radius-md: calc(var(--radius) - 2px);
91
  --radius-lg: var(--radius);
92
  --radius-xl: calc(var(--radius) + 4px);
93
  --color-background: var(--background);
94
  --color-foreground: var(--foreground);
95
  --color-card: var(--card);
96
  --color-card-foreground: var(--card-foreground);
97
  --color-popover: var(--popover);
98
  --color-popover-foreground: var(--popover-foreground);
99
  --color-primary: var(--primary);
100
  --color-primary-foreground: var(--primary-foreground);
101
  --color-secondary: var(--secondary);
102
  --color-secondary-foreground: var(--secondary-foreground);
103
  --color-muted: var(--muted);
104
  --color-muted-foreground: var(--muted-foreground);
105
  --color-accent: var(--accent);
106
  --color-accent-foreground: var(--accent-foreground);
107
  --color-destructive: var(--destructive);
108
  --color-border: var(--border);
109
  --color-input: var(--input);
110
  --color-ring: var(--ring);
111
  --color-chart-1: var(--chart-1);
112
  --color-chart-2: var(--chart-2);
113
  --color-chart-3: var(--chart-3);
114
  --color-chart-4: var(--chart-4);
115
  --color-chart-5: var(--chart-5);
116
  --color-sidebar: var(--sidebar);
117
  --color-sidebar-foreground: var(--sidebar-foreground);
118
  --color-sidebar-primary: var(--sidebar-primary);
119
  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
120
  --color-sidebar-accent: var(--sidebar-accent);
121
  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
122
  --color-sidebar-border: var(--sidebar-border);
123
  --color-sidebar-ring: var(--sidebar-ring);
124
}
125
126
@layer base {
127
  * {
128
    @apply border-border outline-ring/50;
129
  }
130
  html {
131
    @apply scroll-smooth;
132
  }
133
  body {
134
    @apply bg-background text-foreground overscroll-none;
135
  }
136
}
assets/vendor/basecoat/components/accordion.css added +27

@@ -0,0 +1,27 @@

1
/* Accordion */
2
@layer components {
3
  .accordion {
4
    @apply flex w-full flex-col;
5
  }
6
7
  .accordion > details > summary {
8
    @apply relative flex w-full flex-1 cursor-pointer list-none items-start justify-between border border-transparent transition-all outline-none;
9
  }
10
11
  .accordion > details > summary > svg:last-child {
12
    @apply pointer-events-none shrink-0 transition-transform;
13
  }
14
15
  .accordion > details[open] > summary > svg:last-child {
16
    @apply rotate-180;
17
  }
18
19
  .accordion > details[aria-disabled='true'] > summary,
20
  .accordion > details > summary[aria-disabled='true'] {
21
    @apply pointer-events-none opacity-50;
22
  }
23
24
  .accordion > details > :not(summary) {
25
    @apply overflow-hidden;
26
  }
27
}
assets/vendor/basecoat/components/alert.css added +38

@@ -0,0 +1,38 @@

1
/* Alert */
2
@layer components {
3
  .alert {
4
    @apply relative w-full;
5
  }
6
7
  .alert:has(> footer) {
8
    @apply pe-[4.5rem];
9
  }
10
11
  .alert > :is(h2, h3, h4, h5, h6, strong, [data-title]) {
12
    @apply min-h-4;
13
  }
14
15
  .alert > section {
16
    @apply grid justify-items-start gap-1;
17
18
    :where(p, ul, ol) {
19
      @apply m-0;
20
    }
21
22
    :where(p, ul, ol):not(:last-child) {
23
      @apply mb-4;
24
    }
25
26
    ul {
27
      @apply list-inside list-disc;
28
    }
29
30
    ol {
31
      @apply list-inside list-decimal;
32
    }
33
  }
34
35
  .alert > footer {
36
    @apply absolute;
37
  }
38
}
assets/vendor/basecoat/components/avatar.css added +47

@@ -0,0 +1,47 @@

1
/* Avatar */
2
@layer components {
3
  .avatar {
4
    @apply relative inline-flex shrink-0 select-none appearance-none border-0 bg-transparent p-0 align-middle outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 after:absolute after:inset-0 after:rounded-[inherit] after:border after:border-border after:mix-blend-darken dark:after:mix-blend-lighten;
5
  }
6
7
  .avatar > img {
8
    @apply absolute inset-0 aspect-square size-full rounded-[inherit] object-cover;
9
  }
10
11
  .avatar > span:not(.avatar-badge) {
12
    @apply flex size-full items-center justify-center rounded-[inherit] text-sm;
13
  }
14
15
  .avatar[data-size='sm'] > span:not(.avatar-badge) {
16
    @apply text-xs;
17
  }
18
19
  .avatar > .avatar-badge {
20
    @apply absolute end-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none;
21
  }
22
23
  .avatar[data-size='sm'] > .avatar-badge {
24
    @apply size-2 [&>svg]:hidden;
25
  }
26
27
  .avatar > .avatar-badge {
28
    @apply size-2.5 [&>svg]:size-2;
29
  }
30
31
  .avatar[data-size='lg'] > .avatar-badge {
32
    @apply size-3 [&>svg]:size-2;
33
  }
34
35
  .avatar-group {
36
    @apply flex -space-x-2;
37
  }
38
39
  .avatar-group > .avatar,
40
  .avatar-group > [data-count] {
41
    @apply ring-2 ring-background;
42
  }
43
44
  .avatar-group > [data-count] {
45
    @apply relative flex shrink-0 items-center justify-center;
46
  }
47
}
assets/vendor/basecoat/components/badge.css added +6

@@ -0,0 +1,6 @@

1
/* Badge */
2
@layer components {
3
  .badge {
4
    @apply inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none;
5
  }
6
}
assets/vendor/basecoat/components/breadcrumb.css added +30

@@ -0,0 +1,30 @@

1
/* Breadcrumb */
2
@layer components {
3
  .breadcrumb > ol {
4
    @apply m-0 flex list-none flex-wrap items-center p-0 break-words;
5
  }
6
7
  .breadcrumb li {
8
    @apply inline-flex items-center;
9
  }
10
11
  .breadcrumb a {
12
    @apply rounded-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50;
13
  }
14
15
  .breadcrumb [aria-current='page'] {
16
    @apply cursor-default;
17
  }
18
19
  .breadcrumb li[aria-hidden='true'] {
20
    @apply select-none;
21
  }
22
23
  .breadcrumb li > span[aria-hidden='true'] {
24
    @apply flex items-center justify-center;
25
  }
26
27
  .breadcrumb [data-rtl-flip] {
28
    @apply rtl:rotate-180;
29
  }
30
}
assets/vendor/basecoat/components/button-group.css added +54

@@ -0,0 +1,54 @@

1
/* Button Group */
2
@layer components {
3
  .button-group {
4
    @apply isolate inline-flex w-fit items-stretch;
5
6
    &:has(> :is(.dropdown-menu, .popover, .select) > [data-popover][aria-hidden='false']) {
7
      @apply relative z-50;
8
    }
9
    
10
    > .input {
11
      @apply flex-1;
12
    }
13
14
    > *:focus-visible,
15
    > :is(.dropdown-menu, .popover, .select) > button:focus-visible {
16
      @apply relative z-10;
17
    }
18
19
    > hr[role='separator'] {
20
      @apply relative w-px h-auto self-stretch shrink-0 border-0 my-px mx-0;
21
    }
22
23
    &:not([data-orientation='vertical']) {
24
      > *:not(:first-child),
25
      > :is(.dropdown-menu, .popover, .select):not(:first-child) > button {
26
        @apply rounded-s-none border-s-0;
27
      }
28
      > *:not(:last-child),
29
      > :is(.dropdown-menu, .popover, .select):not(:last-child) > button {
30
        @apply rounded-e-none;
31
      }
32
    }  
33
    &[data-orientation='vertical'] {
34
      @apply flex-col;
35
36
      > hr[role='separator'] {
37
        @apply w-auto h-px mx-px my-0;
38
      }
39
      
40
      > *:not(:first-child),
41
      > :is(.dropdown-menu, .popover, .select):not(:first-child) > button {
42
        @apply rounded-t-none border-t-0;
43
      }
44
      > *:not(:last-child),
45
      > :is(.dropdown-menu, .popover, .select):not(:last-child) > button {
46
        @apply rounded-b-none;
47
      }
48
    }
49
  }
50
51
  .button-group > :is(span, label, output) {
52
    @apply flex items-center [&_svg]:pointer-events-none;
53
  }
54
}
assets/vendor/basecoat/components/button.css added +6

@@ -0,0 +1,6 @@

1
/* Button */
2
@layer components {
3
  .btn {
4
    @apply inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0;
5
  }
6
}
assets/vendor/basecoat/components/card.css added +26

@@ -0,0 +1,26 @@

1
/* Card */
2
@layer components {
3
  .card {
4
    @apply flex flex-col;
5
6
    > header {
7
      @apply @container/card-header grid auto-rows-min items-start has-[>[data-slot=card-action],>.card-action]:grid-cols-[1fr_auto] has-[>p]:grid-rows-[auto_auto];
8
    }
9
10
    > header > :is(h2, h3, [data-title], .card-title) {
11
      @apply leading-none;
12
    }
13
14
    > header > :is([data-slot='card-action'], .card-action) {
15
      @apply col-start-2 row-span-2 row-start-1 self-start justify-self-end;
16
    }
17
18
    > header > menu.card-action {
19
      @apply m-0 list-none p-0;
20
    }
21
22
    > footer {
23
      @apply flex items-center;
24
    }
25
  }
26
}
assets/vendor/basecoat/components/chart.css added +56

@@ -0,0 +1,56 @@

1
/* Chart */
2
@layer components {
3
  .chart {
4
    @apply relative flex aspect-video w-full justify-center text-xs;
5
  }
6
7
  .chart > canvas {
8
    @apply block size-full;
9
  }
10
11
  .chart-tooltip {
12
    @apply pointer-events-none fixed z-50 grid min-w-32 -translate-x-1/2 translate-y-2 items-start transition-[left,top,opacity] duration-150 ease-out;
13
  }
14
15
  .chart-tooltip[hidden] {
16
    @apply opacity-0;
17
  }
18
19
  .chart-tooltip-items {
20
    @apply grid gap-1.5;
21
  }
22
23
  .chart-tooltip-item {
24
    @apply flex w-full flex-wrap items-center gap-2;
25
  }
26
27
  .chart-tooltip-indicator {
28
    @apply size-2.5 shrink-0 rounded-[2px];
29
    background-color: var(--chart-indicator-color);
30
  }
31
32
  .chart-legend-indicator {
33
    @apply size-2 shrink-0 rounded-[2px];
34
    background-color: var(--chart-indicator-color);
35
  }
36
37
  .chart-tooltip-label {
38
    @apply text-muted-foreground leading-none;
39
  }
40
41
  .chart-tooltip-value {
42
    @apply ms-auto font-mono font-medium leading-none tabular-nums;
43
  }
44
45
  .chart-legend {
46
    @apply flex flex-wrap items-center justify-center gap-4 pt-3;
47
  }
48
49
  .chart-legend-item {
50
    @apply flex items-center gap-1.5 text-xs;
51
  }
52
53
  .chart-legend-item[aria-pressed='false'] {
54
    @apply opacity-70;
55
  }
56
}
assets/vendor/basecoat/components/checkbox.css added +12

@@ -0,0 +1,12 @@

1
/* Checkbox */
2
@layer components {
3
  .field > input[type='checkbox']:not([role='switch']),
4
  .input[type='checkbox']:not([role='switch']) {
5
    @apply relative shrink-0 appearance-none outline-none disabled:cursor-not-allowed disabled:opacity-50;
6
    
7
    &:checked:after {
8
      @apply content-[''] block bg-current;
9
      @apply mask-[image:var(--check-icon)] mask-size-[0.875rem] mask-no-repeat mask-center;
10
    }
11
  }
12
}
assets/vendor/basecoat/components/collapsible.css added +21

@@ -0,0 +1,21 @@

1
/* Collapsible */
2
@layer components {
3
  details {
4
    &::details-content {
5
      block-size: 0;
6
      @apply block opacity-0 transition-discrete transition-all;
7
    }
8
    &[open]::details-content {
9
      block-size: auto;
10
      block-size: calc-size(auto, size);
11
      @apply opacity-100;
12
    }
13
    summary {
14
      @apply inline-flex items-center cursor-pointer;
15
    }
16
  }
17
  details > summary::-webkit-details-marker {
18
    display: none;
19
  }
20
}
21
assets/vendor/basecoat/components/combobox.css added +99

@@ -0,0 +1,99 @@

1
/* Combobox */
2
@layer components {
3
  .combobox {
4
    @apply relative inline-flex w-fit flex-col;
5
  }
6
7
  .combobox > input[role='combobox'] {
8
    @apply w-full min-w-0 outline-hidden placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50;
9
  }
10
11
  .combobox .input-group input[role='combobox'],
12
  .combobox [data-popover] input[role='combobox'] {
13
    @apply min-w-0 outline-hidden placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50;
14
  }
15
16
  .combobox > svg,
17
  .combobox-trigger-icon {
18
    @apply pointer-events-none absolute shrink-0;
19
  }
20
21
  .combobox:has(> .combobox-chips) > .combobox-trigger-icon {
22
    @apply hidden;
23
  }
24
25
  .combobox [data-clear] {
26
    @apply inline-flex size-6 shrink-0 cursor-default appearance-none items-center justify-center border-0 bg-transparent p-0 text-current disabled:pointer-events-none disabled:opacity-50;
27
  }
28
29
  .combobox [data-clear][hidden] {
30
    @apply hidden;
31
  }
32
33
  .combobox .input-group:has([data-clear]:not([hidden])) button[aria-haspopup='listbox'] {
34
    @apply hidden;
35
  }
36
37
  .combobox [data-popover] {
38
    @apply isolate overflow-hidden;
39
    min-width: max(100%, 9rem);
40
  }
41
42
  .combobox [role='listbox'] {
43
    @apply outline-hidden overflow-x-hidden overflow-y-auto;
44
  }
45
46
  .combobox [role='listbox'] [role='group']:not(:has([role='option']:not([aria-hidden='true']))) {
47
    @apply hidden;
48
  }
49
50
  .combobox [role='listbox']:not(:has([data-value]:not([aria-hidden='true'])))::before {
51
    @apply flex items-center justify-center truncate;
52
  }
53
54
  .combobox [role='listbox'][data-empty]:not(:has([data-value]:not([aria-hidden='true'])))::before {
55
    @apply content-[attr(data-empty)];
56
  }
57
58
  .combobox [role='listbox']:not([data-empty]):not(:has([data-value]:not([aria-hidden='true'])))::before {
59
    @apply content-['No_results_found'];
60
  }
61
62
  .combobox [role='option'] {
63
    @apply aria-hidden:hidden relative flex w-full cursor-default items-center truncate outline-hidden select-none aria-disabled:pointer-events-none aria-disabled:opacity-50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0;
64
  }
65
66
  .combobox [role='option'][aria-selected='true'] {
67
    @apply bg-[image:var(--check-icon)] bg-no-repeat bg-position-[center_right_0.5rem] bg-size-[0.875rem];
68
  }
69
70
  .combobox [role='option'][aria-selected='true']:dir(rtl) {
71
    @apply bg-position-[center_left_0.5rem];
72
  }
73
74
  .combobox [role='separator'] {
75
    @apply border-0;
76
  }
77
78
  .combobox [role='option'].active,
79
  .combobox [role='option']:focus-visible,
80
  .combobox:not([data-combobox-initialized]) [role='option']:not([aria-disabled='true']):not(:disabled):hover {
81
    @apply bg-accent text-accent-foreground;
82
  }
83
84
  .combobox-chips {
85
    @apply flex w-full min-w-0 flex-wrap items-center;
86
  }
87
88
  .combobox-chips > input[role='combobox'] {
89
    @apply min-w-16 flex-1 bg-transparent outline-hidden placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50;
90
  }
91
92
  .combobox-chip {
93
    @apply inline-flex w-fit shrink-0 items-center whitespace-nowrap;
94
  }
95
96
  .combobox-chip-remove {
97
    @apply inline-flex shrink-0 items-center justify-center;
98
  }
99
}
assets/vendor/basecoat/components/command.css added +140

@@ -0,0 +1,140 @@

1
/* Command */
2
@layer components {
3
  .command-dialog {
4
    @apply inset-y-0 bg-transparent p-0 text-inherit opacity-0 transition-all transition-discrete;
5
    max-width: none;
6
    max-height: none;
7
8
    &:is([open], :popover-open) {
9
      @apply opacity-100;
10
11
      &::backdrop {
12
        @apply opacity-100;
13
      }
14
15
      > .command {
16
        @apply scale-100;
17
      }
18
19
      @starting-style {
20
        @apply opacity-0;
21
22
        &::backdrop {
23
          @apply opacity-0;
24
        }
25
26
        > .command {
27
          @apply scale-95;
28
        }
29
      }
30
    }
31
32
    &::backdrop {
33
      @apply opacity-0 transition-all transition-discrete;
34
    }
35
36
    > .command {
37
      @apply fixed top-1/3 left-1/2 z-50 flex max-h-[min(640px,calc(100%_-_2rem))] w-full max-w-[calc(100%_-_2rem)] -translate-x-1/2 translate-y-0 scale-95 flex-col overflow-hidden transition-all sm:max-w-sm;
38
    }
39
40
    > .command > :is(button, form[method='dialog']) {
41
      @apply absolute;
42
    }
43
44
    > .command > :is(button, form[method='dialog'] > button) {
45
      @apply inline-flex items-center justify-center opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0;
46
    }
47
48
    > .command > :is(button, form[method='dialog'] > button) > svg:not([class*='size-']) {
49
      @apply size-4;
50
    }
51
  }
52
53
  .command {
54
    @apply flex w-full flex-col overflow-hidden;
55
  }
56
57
  .command > header {
58
    @apply flex items-center;
59
  }
60
61
  .command > header svg {
62
    @apply shrink-0;
63
  }
64
65
  .command > header input {
66
    @apply w-full bg-transparent outline-hidden disabled:cursor-not-allowed disabled:opacity-50;
67
  }
68
69
  .command [role='menu'] {
70
    @apply overflow-x-hidden overflow-y-auto outline-hidden;
71
    scrollbar-width: none;
72
73
    &::-webkit-scrollbar {
74
      display: none;
75
    }
76
  }
77
78
  .command [role='menuitem'] {
79
    @apply aria-hidden:hidden relative flex w-full cursor-default items-center outline-hidden select-none disabled:pointer-events-none disabled:opacity-50 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0;
80
  }
81
82
  .command [role='menuitem'].active,
83
  .command [role='menuitem'][data-selected='true'] {
84
    @apply bg-muted text-foreground;
85
  }
86
87
  .command [role='group'] {
88
    @apply overflow-hidden;
89
  }
90
91
  .command [role='group']:not(:has([role='menuitem']:not([aria-hidden='true']))) {
92
    @apply hidden;
93
  }
94
95
  .command [role='heading'] {
96
    @apply flex;
97
  }
98
99
  .command [role='separator'] {
100
    @apply border-0;
101
  }
102
103
  .command [data-shortcut] {
104
    @apply ms-auto;
105
  }
106
107
  .command [data-indicator] {
108
    @apply ms-auto opacity-0;
109
  }
110
111
  .command [role='menuitem'][data-checked='true'] [data-indicator],
112
  .command [role='menuitem'][aria-selected='true'] [data-indicator],
113
  .command [role='menuitem'][data-selected='true'] [data-indicator] {
114
    @apply opacity-100;
115
  }
116
117
  .command [role='menuitem']:has([data-shortcut]) [data-indicator] {
118
    @apply hidden;
119
  }
120
121
  .command [role='menu']:not(:has([role='menuitem']:not([aria-hidden='true'])))::before {
122
    @apply flex items-center justify-center truncate;
123
  }
124
125
  .command [role='menu'][data-empty]:not(:has([role='menuitem']:not([aria-hidden='true'])))::before {
126
    @apply content-[attr(data-empty)];
127
  }
128
129
  .command [role='menu']:not([data-empty]):not(:has([role='menuitem']:not([aria-hidden='true'])))::before {
130
    @apply content-['No_results_found'];
131
  }
132
133
  .command:not([data-command-initialized]) [role='menuitem']:not([aria-disabled='true']):not(:disabled):hover {
134
    @apply bg-muted text-foreground;
135
  }
136
137
  .command:has(> header input:not(:placeholder-shown)) [role='separator'] {
138
    @apply hidden;
139
  }
140
}
assets/vendor/basecoat/components/dialog.css added +101

@@ -0,0 +1,101 @@

1
/* Dialog */
2
@layer components {
3
  .dialog,
4
  .alert-dialog {
5
    @apply bg-transparent p-0 text-inherit opacity-0 transition-all transition-discrete backdrop:opacity-0 backdrop:transition-all backdrop:transition-discrete;
6
    max-width: none;
7
    max-height: none;
8
9
    &:is([open], :popover-open) {
10
      @apply opacity-100 backdrop:opacity-100;
11
12
      > * {
13
        @apply scale-100;
14
      }
15
16
      @starting-style {
17
        @apply opacity-0 backdrop:opacity-0;
18
19
        > * {
20
          @apply scale-95;
21
        }
22
      }
23
    }
24
25
    > * {
26
      @apply fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100%_-_2rem)] w-full -translate-x-1/2 -translate-y-1/2 scale-95 flex-col transition-all outline-hidden;
27
    }
28
  }
29
30
  .dialog {
31
    > * > header {
32
      @apply flex flex-col;
33
    }
34
35
    > * > section {
36
      @apply flex-1;
37
    }
38
39
    > * > footer {
40
      @apply flex flex-col-reverse sm:flex-row sm:justify-end;
41
    }
42
43
    > * > :is(button, form[method='dialog']) {
44
      @apply absolute;
45
    }
46
47
    > * > :is(button, form[method='dialog'] > button) {
48
      @apply inline-flex items-center justify-center opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0;
49
    }
50
51
    > * > :is(button, form[method='dialog'] > button) > svg:not([class*='size-']) {
52
      @apply size-4;
53
    }
54
  }
55
56
  .alert-dialog {
57
    > * > header {
58
      @apply grid grid-rows-[auto_1fr] place-items-center text-center sm:place-items-start sm:text-start;
59
    }
60
61
    > * > header:has(> figure) {
62
      @apply grid-rows-[auto_auto_1fr] sm:grid-rows-[auto_1fr];
63
    }
64
65
    > * > header > figure {
66
      @apply m-0 shrink-0 sm:row-span-2 [&_svg]:pointer-events-none [&_svg]:shrink-0;
67
    }
68
69
    > * > header > figure:has(img) {
70
      @apply overflow-hidden [&_img]:size-full [&_img]:object-cover;
71
    }
72
73
    > * > header:has(> figure) > :is(h2, p) {
74
      @apply sm:col-start-2;
75
    }
76
77
    > * > footer {
78
      @apply flex flex-col-reverse sm:flex-row sm:justify-end;
79
    }
80
81
    &:is([data-size='sm']) > * > header,
82
    > [data-size='sm'] > header {
83
      @apply sm:place-items-center sm:text-center;
84
    }
85
86
    &:is([data-size='sm']) > * > header:has(> figure),
87
    > [data-size='sm'] > header:has(> figure) {
88
      @apply sm:grid-rows-[auto_auto_1fr];
89
    }
90
91
    &:is([data-size='sm']) > * > header:has(> figure) > :is(h2, p),
92
    > [data-size='sm'] > header:has(> figure) > :is(h2, p) {
93
      @apply sm:col-start-auto;
94
    }
95
96
    &:is([data-size='sm']) > * > footer,
97
    > [data-size='sm'] > footer {
98
      @apply grid grid-cols-2 sm:grid sm:grid-cols-2;
99
    }
100
  }
101
}
assets/vendor/basecoat/components/drawer.css added +89

@@ -0,0 +1,89 @@

1
/* Drawer */
2
@layer components {
3
  .drawer {
4
    @apply fixed inset-0 m-0 size-auto max-h-none max-w-none overflow-visible bg-transparent p-0 text-inherit opacity-0 transition-all transition-discrete duration-500 ease-[cubic-bezier(0.32,0.72,0,1)] backdrop:opacity-0 backdrop:transition-all backdrop:transition-discrete backdrop:duration-500 backdrop:ease-[cubic-bezier(0.32,0.72,0,1)];
5
6
    > * {
7
      @apply fixed z-50 flex h-auto flex-col transition-transform duration-500 ease-[cubic-bezier(0.32,0.72,0,1)] will-change-transform outline-hidden;
8
    }
9
10
    &:not([data-side]) > *,
11
    &[data-side='bottom'] > * {
12
      @apply inset-x-0 bottom-0 mt-24 max-h-[80vh] translate-y-full;
13
    }
14
15
    &[data-side='top'] > * {
16
      @apply inset-x-0 top-0 mb-24 max-h-[80vh] -translate-y-full;
17
    }
18
19
    &[data-side='left'] > * {
20
      @apply inset-y-0 left-0 w-3/4 max-w-sm -translate-x-full;
21
    }
22
23
    &[data-side='right'] > * {
24
      @apply inset-y-0 right-0 w-3/4 max-w-sm translate-x-full;
25
    }
26
27
    &[data-closing] {
28
      @apply opacity-0 backdrop:opacity-0;
29
    }
30
31
    &[data-closing]:not([data-side]) > *,
32
    &[data-closing][data-side='bottom'] > * {
33
      @apply translate-y-full;
34
    }
35
36
    &[data-closing][data-side='top'] > * {
37
      @apply -translate-y-full;
38
    }
39
40
    &[data-closing][data-side='left'] > * {
41
      @apply -translate-x-full;
42
    }
43
44
    &[data-closing][data-side='right'] > * {
45
      @apply translate-x-full;
46
    }
47
48
    &:is([open], :popover-open):not([data-closing]) {
49
      @apply opacity-100 backdrop:opacity-100;
50
51
      > * {
52
        @apply translate-x-0 translate-y-0;
53
      }
54
55
      @starting-style {
56
        @apply opacity-0 backdrop:opacity-0;
57
58
        &:not([data-side]) > *,
59
        &[data-side='bottom'] > * {
60
          @apply translate-y-full;
61
        }
62
63
        &[data-side='top'] > * {
64
          @apply -translate-y-full;
65
        }
66
67
        &[data-side='left'] > * {
68
          @apply -translate-x-full;
69
        }
70
71
        &[data-side='right'] > * {
72
          @apply translate-x-full;
73
        }
74
      }
75
    }
76
77
    > * > header {
78
      @apply flex flex-col;
79
    }
80
81
    > * > section {
82
      @apply min-h-0 flex-1 overflow-y-auto;
83
    }
84
85
    > * > footer {
86
      @apply mt-auto flex flex-col;
87
    }
88
  }
89
}
assets/vendor/basecoat/components/dropdown-menu.css added +56

@@ -0,0 +1,56 @@

1
/* Dropdown Menu */
2
@layer components {
3
  .dropdown-menu {
4
    @apply relative inline-flex;
5
  }
6
7
  .dropdown-menu > [data-popover] {
8
    min-width: anchor-size(width);
9
  }
10
11
  .dropdown-menu [role='menu'] {
12
    @apply outline-hidden;
13
  }
14
15
  .dropdown-menu :is([role='menuitem'], [role='menuitemcheckbox'], [role='menuitemradio']) {
16
    @apply aria-hidden:hidden relative flex w-full cursor-default items-center truncate outline-hidden select-none disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0;
17
  }
18
19
  .dropdown-menu [role='menu'] [role='heading'] {
20
    @apply flex;
21
  }
22
23
  .dropdown-menu [role='separator'] {
24
    @apply border-0;
25
  }
26
27
  .dropdown-menu :is([data-shortcut], kbd:last-child, .kbd:last-child) {
28
    @apply ms-auto shrink-0 text-end;
29
  }
30
31
  .dropdown-menu [data-shortcut] {
32
    @apply ps-8;
33
  }
34
35
  .dropdown-menu :is(kbd:last-child, .kbd:last-child):not([data-shortcut]) {
36
    @apply ps-8;
37
  }
38
39
  .dropdown-menu [data-indicator] {
40
    @apply pointer-events-none invisible;
41
  }
42
43
  .dropdown-menu :is([role='menuitemcheckbox'], [role='menuitemradio'])[aria-checked='true'] [data-indicator] {
44
    @apply visible;
45
  }
46
47
  .dropdown-menu [data-rtl-flip] {
48
    @apply rtl:rotate-180;
49
  }
50
51
  .dropdown-menu:not([data-dropdown-menu-initialized]) :is([role='menuitem'], [role='menuitemcheckbox'], [role='menuitemradio']) {
52
    &:not([aria-disabled='true']):not(:disabled):hover {
53
      @apply bg-accent text-accent-foreground;
54
    }
55
  }
56
}
assets/vendor/basecoat/components/empty.css added +26

@@ -0,0 +1,26 @@

1
/* Empty */
2
@layer components {
3
  .empty {
4
    @apply flex w-full min-w-0 flex-1 flex-col items-center justify-center text-center text-balance;
5
  }
6
7
  .empty > header {
8
    @apply flex max-w-sm flex-col items-center;
9
  }
10
11
  .empty figure {
12
    @apply flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0;
13
  }
14
15
  .empty figure:has(img) {
16
    @apply overflow-hidden [&_img]:size-full [&_img]:object-cover;
17
  }
18
19
  .empty > :is(section, footer) {
20
    @apply flex w-full max-w-sm min-w-0 flex-col items-center text-balance;
21
  }
22
23
  .empty > header > p {
24
    @apply [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary;
25
  }
26
}
assets/vendor/basecoat/components/field.css added +82

@@ -0,0 +1,82 @@

1
/* Field */
2
@layer components {
3
  .fieldset {
4
    @apply flex flex-col;
5
  }
6
7
  .fieldset > :is([role='group'], [role='radiogroup']):not(.field),
8
  .field > :is(fieldset, [role='group'], [role='radiogroup']):not(.field) {
9
    @apply @container/field-group flex w-full flex-col;
10
  }
11
12
  .field {
13
    @apply flex w-full flex-col data-invalid:text-destructive data-[invalid=true]:text-destructive [&>*]:w-full [&>.sr-only]:w-auto;
14
15
    &[data-orientation='horizontal'] {
16
      @apply flex-row items-center [&>*]:w-auto [&>label]:flex-auto has-[>section]:items-start has-[>section]:[&>section]:flex-1 has-[>section]:[&>[type=checkbox],[type=radio]]:mt-px [&_p]:text-balance;
17
    }
18
19
    &[data-orientation='responsive'] {
20
      @apply flex-col @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto @md/field-group:has-[>section]:items-start @md/field-group:[&>label]:flex-auto @md/field-group:has-[>section]:[&>[type=checkbox],[type=radio]]:mt-px;
21
    }
22
23
    section {
24
      @apply flex flex-1 flex-col leading-snug;
25
    }
26
27
    h2,
28
    h3 {
29
      @apply flex w-fit items-center;
30
    }
31
32
    [role='alert'] {
33
      @apply font-normal;
34
35
      ul {
36
        @apply ms-4 flex list-disc flex-col gap-1;
37
      }
38
    }
39
  }
40
41
  .field > label,
42
  .field > section > label,
43
  label:has(> .field) {
44
    @apply w-fit;
45
  }
46
47
  .field > label:has(> .field),
48
  .field > label:has(input[type='radio']),
49
  .field > label:has(input[type='checkbox']),
50
  .field > label:has(input[role='switch']),
51
  label:has(> .field) {
52
    @apply w-full;
53
  }
54
55
  .field > label:has(> .field),
56
  label:has(> .field) {
57
    @apply flex-col;
58
  }
59
60
  .fieldset legend + p,
61
  .field > p,
62
  .field section > p {
63
    @apply leading-normal font-normal [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4;
64
  }
65
66
  .field > p,
67
  .field section > p {
68
    @apply last:mt-0 nth-last-2:-mt-1;
69
  }
70
71
  .field-separator {
72
    @apply relative;
73
  }
74
75
  .field-separator > hr[role='separator'] {
76
    @apply absolute inset-x-0 top-1/2;
77
  }
78
79
  .field-separator > span {
80
    @apply relative mx-auto block w-fit bg-background;
81
  }
82
}
assets/vendor/basecoat/components/form.css added +9

@@ -0,0 +1,9 @@

1
@import "./checkbox.css";
2
@import "./input.css";
3
@import "./label.css";
4
@import "./radio.css";
5
@import "./range.css";
6
@import "./native-select.css";
7
@import "./select.css";
8
@import "./switch.css";
9
@import "./textarea.css";
assets/vendor/basecoat/components/input-group.css added +74

@@ -0,0 +1,74 @@

1
/* Input Group */
2
@layer components {
3
  .input-group {
4
    @apply relative flex w-full min-w-0 items-center outline-none;
5
6
    &[data-orientation='vertical'],
7
    &:has(> [data-align='block-start']),
8
    &:has(> [data-align='block-end']) {
9
      @apply h-auto flex-col items-stretch;
10
    }
11
  }
12
13
  .input-group > :is(input, textarea, select, [data-control]) {
14
    @apply text-foreground placeholder:text-muted-foreground block min-w-0 flex-1 appearance-none rounded-none border-0 bg-transparent shadow-none outline-none ring-0 focus-visible:ring-0 aria-invalid:ring-0;
15
  }
16
17
  .input-group > :is(input, select, [data-control]) {
18
    @apply h-full;
19
  }
20
21
  .input-group > [data-control] {
22
    @apply w-full;
23
  }
24
25
  .input-group:is([data-orientation='vertical'], :has(> [data-align='block-start']), :has(> [data-align='block-end'])) > :is(input, select, [data-control]) {
26
    @apply h-auto flex-none;
27
  }
28
29
  .input-group[data-orientation='vertical']:has(> footer[data-align='end']) > :is(input, select, [data-control]),
30
  .input-group:has(> [data-align='block-end']) > :is(input, select, [data-control]) {
31
    @apply pt-3;
32
  }
33
34
  .input-group[data-orientation='vertical']:has(> header[data-align='start']) > :is(input, select, [data-control]),
35
  .input-group:has(> [data-align='block-start']) > :is(input, select, [data-control]) {
36
    @apply pb-3;
37
  }
38
39
  .input-group > textarea {
40
    @apply field-sizing-content min-h-20 w-full resize-none;
41
  }
42
43
  .input-group > :is(span, kbd, svg, [role='group'], .popover, .dropdown-menu, button, footer, header, hr[role='separator']) {
44
    @apply shrink-0;
45
  }
46
47
  .input-group > :is(span, [role='group'], footer, header) {
48
    @apply inline-flex items-center;
49
  }
50
51
  .input-group > svg {
52
    @apply pointer-events-none;
53
  }
54
55
  .input-group > :is([data-align='start'], [data-align='inline-start'], [data-align='block-start']) {
56
    @apply order-first;
57
  }
58
59
  .input-group > :is([data-align='end'], [data-align='inline-end'], [data-align='block-end']) {
60
    @apply order-last;
61
  }
62
63
  .input-group > :is([data-align='block-start'], [data-align='block-end']) {
64
    @apply w-full justify-start;
65
  }
66
67
  .input-group > button:is([data-align='start'], [data-align='inline-start']) {
68
    @apply ms-1;
69
  }
70
71
  .input-group > button:is([data-align='end'], [data-align='inline-end']) {
72
    @apply me-1;
73
  }
74
}
assets/vendor/basecoat/components/input.css added +32

@@ -0,0 +1,32 @@

1
/* Input */
2
@layer components {
3
  .field > input[type='text']:not(:is(.command > header input, .dialog > * > header input)),
4
  .field > input[type='email'],
5
  .field > input[type='password'],
6
  .field > input[type='number'],
7
  .field > input[type='file'],
8
  .field > input[type='tel'],
9
  .field > input[type='url'],
10
  .field > input[type='search']:not(:is(.command > header input, .dialog > * > header input)),
11
  .field > input[type='date'],
12
  .field > input[type='datetime-local'],
13
  .field > input[type='month'],
14
  .field > input[type='week'],
15
  .field > input[type='time'],
16
  .input[type='text'],
17
  .input[type='email'],
18
  .input[type='password'],
19
  .input[type='number'],
20
  .input[type='file'],
21
  .input[type='tel'],
22
  .input[type='url'],
23
  .input[type='search'],
24
  .input[type='date'],
25
  .input[type='datetime-local'],
26
  .input[type='month'],
27
  .input[type='week'],
28
  .input[type='time'],
29
  .input:not([type]) {
30
    @apply w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50;
31
  }
32
}
assets/vendor/basecoat/components/item.css added +50

@@ -0,0 +1,50 @@

1
/* Item */
2
@layer components {
3
  .item-group {
4
    @apply flex w-full flex-col;
5
  }
6
7
  .item-group > hr {
8
    @apply w-full shrink-0 border-0 border-t;
9
  }
10
11
  .item {
12
    @apply flex w-full flex-wrap items-center border transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50;
13
  }
14
15
  a.item {
16
    @apply hover:bg-muted;
17
  }
18
19
  .item > figure {
20
    @apply flex shrink-0 items-center justify-center [&_svg]:pointer-events-none;
21
  }
22
23
  .item > figure:has(> img) {
24
    @apply overflow-hidden [&_img]:size-full [&_img]:object-cover;
25
  }
26
27
  .item > section {
28
    @apply flex flex-1 flex-col;
29
  }
30
31
  .item > section + section {
32
    @apply flex-none;
33
  }
34
35
  .item > aside {
36
    @apply flex items-center;
37
  }
38
39
  .item > :is(header, footer) {
40
    @apply flex basis-full items-center justify-between;
41
  }
42
43
  .item :is(h2, h3, h4) {
44
    @apply line-clamp-1 flex w-fit items-center;
45
  }
46
47
  .item p {
48
    @apply text-muted-foreground line-clamp-2 text-start font-normal [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary;
49
  }
50
}
assets/vendor/basecoat/components/kbd.css added +6

@@ -0,0 +1,6 @@

1
/* Kbd */
2
@layer components {
3
  .kbd {
4
    @apply pointer-events-none inline-flex items-center justify-center select-none;
5
  }
6
}
assets/vendor/basecoat/components/label.css added +13

@@ -0,0 +1,13 @@

1
/* Label */
2
@layer components {
3
  .field > label,
4
  .field > section > label,
5
  .label {
6
    @apply flex items-center select-none peer-disabled:pointer-events-none;
7
8
    &:has(>*:disabled),
9
    &:has(+*:disabled) {
10
      @apply pointer-events-none;
11
    }
12
  }
13
}
assets/vendor/basecoat/components/native-select.css added +12

@@ -0,0 +1,12 @@

1
/* Native Select */
2
@layer components {
3
  .field > select,
4
  select.select {
5
    @apply min-w-0 appearance-none outline-none select-none disabled:cursor-not-allowed disabled:opacity-50;
6
7
    option,
8
    optgroup {
9
      @apply bg-[Canvas] text-[CanvasText];
10
    }
11
  }
12
}
assets/vendor/basecoat/components/popover.css added +107

@@ -0,0 +1,107 @@

1
/* Popover */
2
@layer components {
3
  [data-popover] {
4
    @apply absolute z-50 min-w-full w-max overflow-x-hidden overflow-y-auto visible opacity-100 scale-100 transition-all outline-hidden;
5
6
    &[aria-hidden='true'] {
7
      @apply invisible opacity-0 scale-95;
8
9
      &:not([data-side]),
10
      &[data-side='bottom'] {
11
        @apply -translate-y-2;
12
      }
13
14
      &[data-side='top'] {
15
        @apply translate-y-2;
16
      }
17
18
      &[data-side='left'] {
19
        @apply translate-x-2;
20
      }
21
22
      &[data-side='right'] {
23
        @apply -translate-x-2;
24
      }
25
26
      &[data-side='inline-start'] {
27
        @apply translate-x-2;
28
      }
29
30
      &[data-side='inline-start']:dir(rtl) {
31
        @apply -translate-x-2;
32
      }
33
34
      &[data-side='inline-end'] {
35
        @apply -translate-x-2;
36
      }
37
38
      &[data-side='inline-end']:dir(rtl) {
39
        @apply translate-x-2;
40
      }
41
    }
42
43
    &:not([data-side]),
44
    &[data-side='bottom'] {
45
      @apply mt-1 top-full;
46
    }
47
48
    &[data-side='top'] {
49
      @apply mb-1 bottom-full;
50
    }
51
52
    &[data-side='left'] {
53
      @apply me-1 end-full;
54
    }
55
56
    &[data-side='right'] {
57
      @apply ms-1 start-full;
58
    }
59
60
    &[data-side='inline-start'] {
61
      @apply me-1 end-full;
62
    }
63
64
    &[data-side='inline-end'] {
65
      @apply ms-1 start-full;
66
    }
67
68
    &:not([data-side]),
69
    &[data-side='bottom'],
70
    &[data-side='top'] {
71
      &:not([data-align]),
72
      &[data-align='start'] {
73
        @apply start-0;
74
      }
75
76
      &[data-align='end'] {
77
        @apply end-0;
78
      }
79
80
      &[data-align='center'] {
81
        @apply start-1/2 -translate-x-1/2;
82
      }
83
    }
84
85
    &[data-side='left'],
86
    &[data-side='right'],
87
    &[data-side='inline-start'],
88
    &[data-side='inline-end'] {
89
      &:not([data-align]),
90
      &[data-align='start'] {
91
        @apply top-0;
92
      }
93
94
      &[data-align='end'] {
95
        @apply bottom-0;
96
      }
97
98
      &[data-align='center'] {
99
        @apply top-1/2 -translate-y-1/2;
100
      }
101
    }
102
  }
103
104
  .popover {
105
    @apply relative inline-flex;
106
  }
107
}
assets/vendor/basecoat/components/progress.css added +14

@@ -0,0 +1,14 @@

1
/* Progress */
2
@layer components {
3
  .progress {
4
    @apply flex w-full items-center overflow-hidden;
5
  }
6
7
  .progress:dir(rtl) {
8
    @apply flex-row-reverse;
9
  }
10
11
  .progress > span {
12
    @apply h-full transition-all;
13
  }
14
}
assets/vendor/basecoat/components/radio.css added +11

@@ -0,0 +1,11 @@

1
/* Radio */
2
@layer components {
3
  .field > input[type='radio'],
4
  .input[type='radio'] {
5
    @apply relative shrink-0 appearance-none border outline-none disabled:cursor-not-allowed disabled:opacity-50;
6
7
    &:checked:before {
8
      @apply absolute top-1/2 left-1/2 content-[''] -translate-x-1/2 -translate-y-1/2 rounded-full;
9
    }
10
  }
11
}
assets/vendor/basecoat/components/range.css added +50

@@ -0,0 +1,50 @@

1
/* Range */
2
@layer components {
3
  .field > input[type='range'],
4
  .input[type='range'] {
5
    @apply flex appearance-none items-center p-0 outline-none disabled:pointer-events-none disabled:opacity-50;
6
    --slider-value: 20%;
7
8
    &::-webkit-slider-thumb {
9
      @apply block shrink-0 appearance-none;
10
    }
11
12
    &::-webkit-slider-runnable-track {
13
      @apply w-full appearance-none;
14
      background: linear-gradient(to right, var(--primary) var(--slider-value), var(--muted) var(--slider-value));
15
    }
16
17
    &:dir(rtl)::-webkit-slider-runnable-track {
18
      background: linear-gradient(to left, var(--primary) var(--slider-value), var(--muted) var(--slider-value));
19
    }
20
21
    &::-moz-range-thumb {
22
      @apply block shrink-0 appearance-none;
23
    }
24
25
    &::-moz-range-track {
26
      @apply w-full appearance-none;
27
      background: linear-gradient(to right, var(--primary) var(--slider-value), var(--muted) var(--slider-value));
28
    }
29
30
    &:dir(rtl)::-moz-range-track {
31
      background: linear-gradient(to left, var(--primary) var(--slider-value), var(--muted) var(--slider-value));
32
    }
33
34
    &::-ms-thumb {
35
      @apply block shrink-0 appearance-none;
36
    }
37
38
    &::-ms-track {
39
      @apply w-full appearance-none;
40
    }
41
42
    &::-ms-fill-lower {
43
      @apply bg-primary;
44
    }
45
46
    &::-ms-fill-upper {
47
      @apply bg-muted;
48
    }
49
  }
50
}
assets/vendor/basecoat/components/scrollbar.css added +46

@@ -0,0 +1,46 @@

1
/* Scrollbar */
2
@layer components {
3
  .scrollbar {
4
    scrollbar-width: auto;
5
    scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
6
7
    &::-webkit-scrollbar {
8
      width: var(--scrollbar-width);
9
      height: var(--scrollbar-width);
10
    }
11
    &::-webkit-scrollbar-track {
12
      background: var(--scrollbar-track);
13
    }
14
    &::-webkit-scrollbar-thumb {
15
      background: var(--scrollbar-thumb);
16
      border-radius: var(--scrollbar-radius);
17
      border: 1px solid transparent;
18
      background-clip: padding-box;
19
    }
20
    &::-webkit-scrollbar-corner {
21
      background: var(--scrollbar-track);
22
    }
23
  }
24
25
  .scrollbar-sm {
26
    scrollbar-width: thin;
27
    scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
28
29
    &::-webkit-scrollbar {
30
      width: var(--scrollbar-sm-width);
31
      height: var(--scrollbar-sm-width);
32
    }
33
    &::-webkit-scrollbar-track {
34
      background: var(--scrollbar-track);
35
    }
36
    &::-webkit-scrollbar-thumb {
37
      background: var(--scrollbar-thumb);
38
      border-radius: var(--scrollbar-radius);
39
      border: 1px solid transparent;
40
      background-clip: padding-box;
41
    }
42
    &::-webkit-scrollbar-corner {
43
      background: var(--scrollbar-track);
44
    }
45
  }
46
}
assets/vendor/basecoat/components/select.css added +64

@@ -0,0 +1,64 @@

1
/* Select */
2
@layer components {
3
  .select:not(select) {
4
    @apply relative inline-flex;
5
  }
6
7
  .select:not(select) > button {
8
    @apply flex w-fit items-center justify-between whitespace-nowrap font-normal outline-hidden disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0;
9
  }
10
11
  .select:not(select) > button > span {
12
    @apply flex flex-1 truncate text-start;
13
  }
14
15
  .select:not(select) > button > svg {
16
    @apply shrink-0;
17
  }
18
19
  .select:not(select) [data-popover] {
20
    @apply isolate overflow-x-hidden;
21
    min-width: max(100%, 9rem);
22
  }
23
24
  .select:not(select) [role='listbox'] {
25
    @apply outline-hidden;
26
  }
27
28
  .select:not(select) [role='listbox'] [role='group']:not(:has([role='option']:not([aria-hidden='true']))) {
29
    @apply hidden;
30
  }
31
32
  .select:not(select) [role='listbox']:not(:has([data-value]:not([aria-hidden='true'])))::before {
33
    @apply flex items-center justify-center truncate;
34
  }
35
36
  .select:not(select) [role='listbox'][data-empty]:not(:has([data-value]:not([aria-hidden='true'])))::before {
37
    @apply content-[attr(data-empty)];
38
  }
39
40
  .select:not(select) [role='listbox']:not([data-empty]):not(:has([data-value]:not([aria-hidden='true'])))::before {
41
    @apply content-['No_results_found'];
42
  }
43
44
  .select:not(select) [role='option'] {
45
    @apply aria-hidden:hidden relative flex w-full cursor-default items-center truncate outline-hidden select-none aria-disabled:pointer-events-none aria-disabled:opacity-50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0;
46
  }
47
48
  .select:not(select) [role='option'][aria-selected='true'] {
49
    @apply bg-[image:var(--check-icon)] bg-no-repeat bg-position-[center_right_0.5rem] bg-size-[0.875rem];
50
  }
51
52
  .select:not(select) [role='option'][aria-selected='true']:dir(rtl) {
53
    @apply bg-position-[center_left_0.5rem];
54
  }
55
56
  .select:not(select) [role='separator'] {
57
    @apply border-0;
58
  }
59
60
  .select:not(select):not([data-select-initialized]) [role='option']:not([aria-disabled='true']):not(:disabled):hover,
61
  .select:not(select) [role='option'].active {
62
    @apply bg-accent text-accent-foreground;
63
  }
64
}
assets/vendor/basecoat/components/sidebar.css added +101

@@ -0,0 +1,101 @@

1
/* Sidebar */
2
@layer components {
3
  .sidebar {
4
    &:not([data-sidebar-initialized]) {
5
      @apply max-md:hidden;
6
    }
7
    &:not([aria-hidden]),
8
    &[aria-hidden=false] {
9
      @apply max-md:bg-black/50 max-md:fixed max-md:inset-0 max-md:z-40;
10
    }
11
    nav {
12
      @apply fixed inset-y-0 z-50 flex w-(--sidebar-mobile-width) flex-col transition-transform duration-300 ease-in-out md:w-(--sidebar-width);
13
    }
14
    & + * {
15
      @apply transition-[margin] ease-in-out duration-300;
16
    }
17
    &:not([data-side]),
18
    &[data-side=left] {
19
      nav {
20
        @apply left-0 border-r;
21
      }
22
      & + * {
23
        @apply relative md:ml-(--sidebar-width);
24
      }
25
      &[aria-hidden=true] {
26
        nav {
27
          @apply -translate-x-full;
28
        }
29
        & + * {
30
          @apply md:ml-0;
31
        }
32
      }
33
    }
34
    &[data-side=right] {
35
      nav {
36
        @apply right-0 border-l;
37
      }
38
      & + * {
39
        @apply relative md:mr-(--sidebar-width);
40
      }
41
      &[aria-hidden=true] {
42
        nav {
43
          @apply translate-x-full;
44
        }
45
        & + * {
46
          @apply md:mr-0;
47
        }
48
      }
49
    }
50
    nav {
51
      > header,
52
      > footer {
53
        @apply flex flex-col;
54
      }
55
      [role=separator] {
56
        @apply w-auto;
57
      }
58
      > section {
59
        @apply flex min-h-0 flex-1 flex-col overflow-y-auto;
60
61
        > [role=group] {
62
          @apply relative flex w-full min-w-0 flex-col;
63
        }
64
        h3 {
65
          @apply flex shrink-0 items-center outline-hidden transition-[margin,opacity] duration-200 ease-linear [&>svg]:shrink-0;
66
        }
67
        ul {
68
          @apply flex w-full min-w-0 flex-col;
69
70
          li {
71
            @apply relative;
72
73
            > a,
74
            > button,
75
            > details > summary {
76
              @apply flex w-full items-center overflow-hidden text-left outline-hidden transition-[width,height,padding] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:shrink-0;
77
            }
78
            > details {
79
              &:not([open]) {
80
                > summary {
81
                  &::after {
82
                    @apply -rotate-90 rtl:rotate-90;
83
                  }
84
                }
85
              }
86
              > summary {
87
                &::after {
88
                  @apply ms-auto block size-3.5 bg-current content-[''] transition-transform ease-linear;
89
                  @apply mask-[image:var(--chevron-down-icon)] mask-size-[1rem] mask-no-repeat mask-center;
90
                }
91
              }
92
            }
93
          }
94
          ul {
95
            @apply flex min-w-0 flex-col;
96
          }
97
        }
98
      }
99
    }
100
  }
101
}
assets/vendor/basecoat/components/skeleton.css added +6

@@ -0,0 +1,6 @@

1
/* Skeleton */
2
@layer components {
3
  .skeleton {
4
    @apply animate-pulse;
5
  }
6
}
assets/vendor/basecoat/components/switch.css added +8

@@ -0,0 +1,8 @@

1
/* Switch */
2
@layer components {
3
  .field > input[type='checkbox'][role='switch'],
4
  .input[type='checkbox'][role='switch'] {
5
    @apply inline-flex shrink-0 appearance-none items-center transition-all outline-none disabled:cursor-not-allowed disabled:opacity-50;
6
    @apply before:pointer-events-none before:block before:content-[''] before:ring-0 before:transition-transform;
7
  }
8
}
assets/vendor/basecoat/components/table.css added +34

@@ -0,0 +1,34 @@

1
/* Tables */
2
@layer components {
3
  .table-container {
4
    @apply relative w-full overflow-x-auto;
5
  }
6
7
  .table {
8
    @apply w-full caption-bottom;
9
10
    thead {
11
      @apply [&_tr]:border-b;
12
    }
13
14
    tbody {
15
      @apply [&_tr:last-child]:border-0;
16
    }
17
18
    tfoot {
19
      @apply border-t font-medium [&>tr]:last:border-b-0;
20
    }
21
22
    tr {
23
      @apply border-b transition-colors data-[state=selected]:bg-muted;
24
    }
25
26
    th {
27
      @apply align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pe-0 [&>[role=checkbox]]:translate-y-[2px];
28
    }
29
30
    td {
31
      @apply align-middle whitespace-nowrap [&:has([role=checkbox])]:pe-0 [&>[role=checkbox]]:translate-y-[2px];
32
    }
33
  }
34
}
assets/vendor/basecoat/components/tabs.css added +31

@@ -0,0 +1,31 @@

1
/* Tabs */
2
@layer components {
3
  .tabs {
4
    @apply flex;
5
6
    &:has(> [role='tablist'][aria-orientation='horizontal']),
7
    &:not(:has(> [role='tablist'][aria-orientation='vertical'])) {
8
      @apply flex-col;
9
    }
10
11
    &:has(> [role='tablist'][aria-orientation='vertical']) {
12
      @apply flex-row;
13
    }
14
15
    > [role='tablist'] {
16
      @apply text-muted-foreground inline-flex w-fit items-center justify-center;
17
18
      &[aria-orientation='vertical'] {
19
        @apply h-fit flex-col;
20
      }
21
22
      > [role='tab'] {
23
        @apply relative inline-flex flex-1 items-center justify-center whitespace-nowrap transition-all outline-none disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0;
24
      }
25
    }
26
27
    > [role='tabpanel'] {
28
      @apply flex-1 outline-none;
29
    }
30
  }
31
}
assets/vendor/basecoat/components/textarea.css added +7

@@ -0,0 +1,7 @@

1
/* Textarea */
2
@layer components {
3
  .field > textarea,
4
  .textarea {
5
    @apply flex field-sizing-content min-h-16 w-full outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50;
6
  }
7
}
assets/vendor/basecoat/components/toast.css added +57

@@ -0,0 +1,57 @@

1
/* Toasts */
2
@layer components {
3
  .toaster {
4
    @apply pointer-events-none fixed bottom-0 z-50 flex w-full flex-col-reverse gap-4 p-4 sm:max-w-90;
5
6
    &:not([data-align]),
7
    &[data-align='end'] {
8
      @apply right-0;
9
    }
10
    &[data-align='start'] {
11
      @apply left-0;
12
    }
13
    &[data-align='center'] {
14
      @apply left-1/2 -translate-x-1/2;
15
    }
16
    .toast {
17
      @apply pointer-events-auto grid w-full grid-rows-[1fr] animate-[toast-up_0.3s_ease-in-out] transition-[grid-template-rows,opacity] duration-300 ease-in-out;
18
      
19
      .toast-content {
20
        @apply flex items-center gap-2.5 overflow-hidden p-3 text-[13px];
21
22
        svg {
23
          @apply size-4 shrink-0;
24
        }
25
        section {
26
          h2 {
27
            @apply font-medium tracking-tight;
28
          }
29
          p {
30
            @apply text-muted-foreground break-all;
31
          }
32
        }
33
        footer {
34
          @apply ms-auto flex flex-col gap-2;
35
36
          [data-toast-action],
37
          [data-toast-cancel] {
38
            @apply h-6 px-2.5 text-xs;
39
          }
40
        }
41
      }
42
      &[aria-hidden='true'] {
43
        @apply grid-rows-[0fr] overflow-hidden border-0 p-0 opacity-0;
44
45
        .toast-content {
46
          @apply border-0;
47
        }
48
      }
49
    }
50
  }
51
}
52
@keyframes toast-up {
53
  from {
54
    opacity: 0;
55
    transform: translateY(100%);
56
  }
57
}
assets/vendor/basecoat/components/tooltip.css added +82

@@ -0,0 +1,82 @@

1
/* Tooltip */
2
@layer components {
3
  [data-tooltip] {
4
    @apply relative;
5
6
    &:before {
7
      @apply absolute z-[60] w-fit max-w-xs origin-(--transform-origin) scale-95 truncate invisible opacity-0 transition-all pointer-events-none content-[attr(data-tooltip)];
8
    }
9
10
    &:hover:before {
11
      @apply visible opacity-100 scale-100;
12
    }
13
14
    &:focus-visible:not(:hover):before {
15
      @apply hidden;
16
    }
17
18
    &:not([data-side]),
19
    &[data-side='top'] {
20
      @apply before:bottom-full before:mb-1.5 before:translate-y-2 hover:before:translate-y-0;
21
    }
22
23
    &[data-side='bottom'] {
24
      @apply before:top-full before:mt-1.5 before:-translate-y-2 hover:before:translate-y-0;
25
    }
26
27
    &:not([data-side]),
28
    &[data-side='top'],
29
    &[data-side='bottom'] {
30
      &[data-align='start'] {
31
        @apply before:left-0;
32
      }
33
      &[data-align='end'] {
34
        @apply before:right-0;
35
      }
36
      &:not([data-align]),
37
      &[data-align='center'] {
38
        @apply before:left-1/2 before:-translate-x-1/2;
39
      }
40
    }
41
42
    &[data-side='left'] {
43
      @apply before:right-full before:mr-1.5 before:translate-x-2 hover:before:translate-x-0;
44
    }
45
46
    &[data-side='right'] {
47
      @apply before:left-full before:ml-1.5 before:-translate-x-2 hover:before:translate-x-0;
48
    }
49
50
    &[data-side='inline-start'] {
51
      @apply before:right-full before:mr-1.5 before:translate-x-2 hover:before:translate-x-0;
52
    }
53
54
    &[data-side='inline-start']:dir(rtl) {
55
      @apply before:right-auto before:left-full before:mr-0 before:ml-1.5 before:-translate-x-2 hover:before:translate-x-0;
56
    }
57
58
    &[data-side='inline-end'] {
59
      @apply before:left-full before:ml-1.5 before:-translate-x-2 hover:before:translate-x-0;
60
    }
61
62
    &[data-side='inline-end']:dir(rtl) {
63
      @apply before:left-auto before:right-full before:ml-0 before:mr-1.5 before:translate-x-2 hover:before:translate-x-0;
64
    }
65
66
    &[data-side='left'],
67
    &[data-side='right'],
68
    &[data-side='inline-start'],
69
    &[data-side='inline-end'] {
70
      &[data-align='start'] {
71
        @apply before:top-0;
72
      }
73
      &[data-align='end'] {
74
        @apply before:bottom-0;
75
      }
76
      &:not([data-align]),
77
      &[data-align='center'] {
78
        @apply before:top-1/2 before:-translate-y-1/2;
79
      }
80
    }
81
  }
82
}
lib/openagents_web/components/layouts.ex modified +76 -30

@@ -5,6 +5,8 @@ defmodule OpenAgentsWeb.Layouts do

5 5
  """
6 6
  use OpenAgentsWeb, :html
7 7
8
  alias OpenAgentsWeb.SarahUI, as: UI
9
8 10
  # Embed all files in layouts/* within this module.
9 11
  # The default root.html.heex file contains the HTML
10 12
  # skeleton of your application, namely HTML headers

@@ -49,7 +51,7 @@ defmodule OpenAgentsWeb.Layouts do

49 51
      <% end %>
50 52
51 53
      <div class="flex-1 min-w-0 flex flex-col h-screen">
52
        <.command_bar current_scope={@current_scope} title={@title} subtitle={@subtitle} />
54
        <.openagents_command_bar current_scope={@current_scope} title={@title} subtitle={@subtitle} />
53 55
54 56
        <main class={[
55 57
          "flex-1 min-w-0 overflow-y-auto overscroll-none p-4",

@@ -74,7 +76,7 @@ defmodule OpenAgentsWeb.Layouts do

74 76
  attr :title, :string, default: nil
75 77
  attr :subtitle, :string, default: nil
76 78
77
  defp command_bar(assigns) do
79
  defp openagents_command_bar(assigns) do
78 80
    ~H"""
79 81
    <header class="navbar bg-base-100 border-b border-base-300 px-4 h-16 shrink-0">
80 82
      <div class="navbar-start">

@@ -106,38 +108,82 @@ defmodule OpenAgentsWeb.Layouts do

106 108
    """
107 109
  end
108 110
111
  @doc """
112
  The Sarah-style command bar: brand lockup on the left, account controls on the
113
  right. `current_user` is optional because public surfaces are anonymous.
114
  """
115
  attr :aria_label, :string, required: true
109 116
  attr :current_user, :map, default: nil
110
  attr :context, :atom, default: :bar
117
  slot :lockup, doc: "chip controls rendered beside the brand name"
118
  slot :controls, doc: "surface-specific controls rendered before the account menu"
119
120
  def command_bar(assigns) do
121
    ~H"""
122
    <header class="command-bar" aria-label={@aria_label}>
123
      <div class="brand-lockup">
124
        <span class="brand-name">OpenAgents</span>
125
        {render_slot(@lockup)}
126
      </div>
127
      <div class="command-controls">
128
        {render_slot(@controls)}
129
130
        <.account_control :if={@current_user} current_user={@current_user} />
131
      </div>
132
    </header>
133
    """
134
  end
135
136
  @doc """
137
  The one authenticated identity control: an avatar trigger opening a bounded
138
  native popover with the same identity and a labeled `LOG OUT` action.
139
  """
140
  attr :current_user, :map, required: true
141
  attr :context, :atom, values: [:bar, :row], default: :bar
111 142
112 143
  def account_control(assigns) do
113 144
    ~H"""
114
    <%= if @current_user do %>
115
      <details class="dropdown dropdown-end">
116
        <summary class="btn btn-ghost btn-circle avatar list-none cursor-pointer">
117
          <img
118
            src={@current_user.github_avatar_url}
119
            alt={"GitHub avatar for @#{@current_user.github_login}"}
120
            class="w-8 h-8 rounded-full"
121
          />
122
        </summary>
123
        <ul class="menu dropdown-content bg-base-100 rounded-box z-10 w-56 p-2 shadow border border-base-300">
124
          <li class="p-2">
125
            <span class="font-semibold">{account_display_name(@current_user)}</span>
126
            <span :if={@current_user.github_name} class="text-sm text-base-content/70">
127
              @{@current_user.github_login}
128
            </span>
129
          </li>
130
          <li>
131
            <.form for={%{}} as={:logout} action={~p"/logout"} method="post" class="m-0 w-full">
132
              <input type="hidden" name="_method" value="delete" />
133
              <button type="submit" class="w-full text-left flex items-center gap-2">
134
                <.icon name="hero-arrow-right-start-on-rectangle" class="size-4" /> Log out
135
              </button>
136
            </.form>
137
          </li>
138
        </ul>
139
      </details>
140
    <% end %>
145
    <UI.button
146
      id="account-menu-trigger"
147
      variant={if(@context == :row, do: :ghost, else: :secondary)}
148
      size={:sm}
149
      class={["account-menu-trigger", @context == :row && "account-menu-trigger--row"]}
150
      popovertarget="account-menu"
151
      popovertargetaction="toggle"
152
      aria-label={"Account menu for @#{@current_user.github_login}"}
153
    >
154
      <UI.avatar src={@current_user.github_avatar_url} size={:sm} />
155
      <span :if={@context == :bar}>@{@current_user.github_login}</span>
156
      <span :if={@context == :row} class="account-trigger-identity">
157
        <strong>{account_display_name(@current_user)}</strong>
158
        <small :if={@current_user.github_name} class="!text-[0.75rem]">@{@current_user.github_login}</small>
159
      </span>
160
      <UI.icon name="chevron-down" class="account-menu-trigger__caret" />
161
    </UI.button>
162
163
    <UI.menu id="account-menu" class="account-menu">
164
      <div class="account-menu__identity">
165
        <UI.avatar
166
          src={@current_user.github_avatar_url}
167
          alt={"GitHub avatar for @#{@current_user.github_login}"}
168
          size={:lg}
169
        />
170
        <span>
171
          <strong>{account_display_name(@current_user)}</strong>
172
          <small :if={@current_user.github_name}>@{@current_user.github_login}</small>
173
        </span>
174
      </div>
175
      <.form for={%{}} id="logout-form" action={~p"/logout"} method="delete">
176
        <UI.button
177
          id="logout"
178
          variant={:ghost}
179
          type="submit"
180
          role="menuitem"
181
          class="account-menu__logout"
182
        >
183
          <UI.icon name="logout" /> Log out
184
        </UI.button>
185
      </.form>
186
    </UI.menu>
141 187
    """
142 188
  end
143 189
lib/openagents_web/live/admin_forge_live.ex added +150

@@ -0,0 +1,150 @@

1
defmodule OpenAgentsWeb.AdminForgeLive do
2
  @moduledoc """
3
  The forge deploy panel: recent pushes, the current fleet target and its
4
  deploy-lane status, deploy receipts with the push→live loop time, and the
5
  one write the operator surface permits — **Promote** (ADMIN-001 as amended
6
  2026-08-18): approving an already-pushed commit as the fleet target,
7
  receipted with the operator's identity. Only WAL-pushed SHAs are
8
  promotable; everything else here is a read-only projection that updates
9
  live off the forge PubSub topics.
10
  """
11
12
  use OpenAgentsWeb, :sarah_live_view
13
14
  alias OpenAgents.Forge
15
  alias OpenAgents.Forge.Targets
16
17
  @impl true
18
  def mount(_params, _session, socket) do
19
    if connected?(socket) do
20
      Enum.each(["forge:pushes", "forge:target", "forge:builds", "forge:deploys"], fn topic ->
21
        Phoenix.PubSub.subscribe(OpenAgents.PubSub, topic)
22
      end)
23
    end
24
25
    {:ok,
26
     socket
27
     |> assign(:page_title, "Operator · Forge")
28
     |> assign(:repo, primary_repo())
29
     |> load()}
30
  end
31
32
  @impl true
33
  def handle_event("promote", %{"sha" => sha}, socket) do
34
    operator = "operator:" <> to_string(socket.assigns.current_user.github_id)
35
36
    socket =
37
      case Targets.promote(socket.assigns.repo, sha, operator) do
38
        {:ok, _target} ->
39
          put_flash(socket, :info, "Promoted #{String.slice(sha, 0, 12)} as the fleet target.")
40
41
        {:error, :unknown_sha} ->
42
          put_flash(
43
            socket,
44
            :error,
45
            "That commit is not in the forge — only pushed commits are promotable."
46
          )
47
48
        {:error, _reason} ->
49
          put_flash(socket, :error, "Promotion failed.")
50
      end
51
52
    {:noreply, load(socket)}
53
  end
54
55
  @impl true
56
  def handle_info(message, socket)
57
      when elem(message, 0) in [
58
             :forge_push,
59
             :forge_target,
60
             :forge_target_status,
61
             :forge_build_ready,
62
             :forge_deploy
63
           ] do
64
    {:noreply, load(socket)}
65
  end
66
67
  def handle_info(_message, socket), do: {:noreply, socket}
68
69
  defp load(socket) do
70
    repo = socket.assigns.repo
71
72
    socket
73
    |> assign(:pushes, Forge.recent_pushes(repo))
74
    |> assign(:target, Targets.current(repo))
75
    |> assign(:targets, Targets.recent(repo))
76
    |> assign(:deploys, Forge.recent_deploys(repo))
77
  end
78
79
  defp primary_repo do
80
    OpenAgents.Forge.Repos.allowed_repos() |> List.first() || "sarah"
81
  end
82
83
  defp short(sha) when is_binary(sha), do: String.slice(sha, 0, 12)
84
  defp short(_), do: "—"
85
86
  defp loop_time(nil), do: "—"
87
  defp loop_time(ms) when is_integer(ms), do: "#{Float.round(ms / 1000, 1)}s"
88
  defp loop_time(_), do: "—"
89
90
  @impl true
91
  def render(assigns) do
92
    ~H"""
93
    <Layouts.app flash={@flash}>
94
      <main id="admin-forge-page" class="app-shell admin-shell">
95
        <section class="admin" aria-label="Forge deploy lane">
96
          <h1>Forge — {@repo}</h1>
97
98
          <div class="admin-totals" aria-label="Current fleet target">
99
            <div :if={@target}>
100
              <strong>Target:</strong> {short(@target.sha)} · {@target.status}
101
              <span :if={@target.details != %{}}>· {inspect(
102
                Map.take(@target.details, ~w(error modules))
103
              )}</span>
104
              <span> · promoted by {@target.promoted_by}</span>
105
            </div>
106
            <div :if={is_nil(@target)}>No fleet target promoted yet.</div>
107
          </div>
108
109
          <h2>Recent pushes</h2>
110
          <div :if={@pushes == []}>No pushes yet — the forge is seeded by the first push.</div>
111
          <div :for={push <- @pushes} class="admin-row" id={"push-#{push.wal_seq}"}>
112
            <div class="admin-identity">
113
              seq {push.wal_seq} · {push.principal} · {Enum.map_join(push.refs, ", ", fn {name,
114
                                                                                          change} ->
115
                "#{name} → #{short(change["new"])}"
116
              end)}
117
            </div>
118
            <div class="admin-state">
119
              <button
120
                :for={{_name, %{"new" => sha}} <- Enum.take(push.refs, 1)}
121
                :if={is_binary(sha)}
122
                phx-click="promote"
123
                phx-value-sha={sha}
124
              >
125
                Promote {short(sha)}
126
              </button>
127
            </div>
128
          </div>
129
130
          <h2>Deploys</h2>
131
          <div :if={@deploys == []}>No deploys yet.</div>
132
          <div :for={deploy <- @deploys} class="admin-row" id={"deploy-#{deploy.id}"}>
133
            <div class="admin-identity">
134
              {short(deploy.sha)} · {deploy.result} · modules {length(deploy.modules)} ·
135
              push→live {loop_time(deploy.push_to_live_ms)}
136
            </div>
137
          </div>
138
139
          <h2>Target history</h2>
140
          <div :for={target <- @targets} class="admin-row" id={"target-#{target.id}"}>
141
            <div class="admin-identity">
142
              {short(target.sha)} · {target.status} · {target.promoted_by}
143
            </div>
144
          </div>
145
        </section>
146
      </main>
147
    </Layouts.app>
148
    """
149
  end
150
end
lib/openagents_web/live/admin_live.ex added +257

@@ -0,0 +1,257 @@

1
defmodule OpenAgentsWeb.AdminLive do
2
  @moduledoc """
3
  The operator surface: every voice call, newest first, with its audio.
4
5
  Read-only by construction. `OpenAgents.Admin` exposes no write, so nothing here can
6
  ban an account, alter a conversation, or change configuration
7
  (`INVARIANTS.md` ADMIN-001). The only actions are paging and playback.
8
9
  Two presentation rules are deliberate:
10
11
    * Calls with no audio are listed with the reason. A panel that hid them would
12
      look like an empty history instead of an honest one — every call from before
13
      recording shipped has none.
14
    * Transcript *content* never appears, only whether a transcript exists. The
15
      operator is here to listen, and cross-account reading of what was said is a
16
      separate decision from cross-account listening.
17
  """
18
19
  use OpenAgentsWeb, :sarah_live_view
20
21
  alias OpenAgents.Admin
22
  alias OpenAgents.Admin.Call
23
  alias OpenAgents.Voice.Recordings
24
25
  @impl true
26
  def mount(_params, _session, socket) do
27
    {:ok,
28
     socket
29
     |> assign(:page_title, "Operator · Sarah")
30
     |> assign(:offset, 0)
31
     |> assign(:recording_config, Recordings.config())
32
     |> load_page()}
33
  end
34
35
  @impl true
36
  def handle_event("next_page", _params, socket) do
37
    offset = socket.assigns.offset + page_size()
38
39
    if offset < socket.assigns.totals.calls,
40
      do: {:noreply, socket |> assign(:offset, offset) |> load_page()},
41
      else: {:noreply, socket}
42
  end
43
44
  def handle_event("previous_page", _params, socket) do
45
    offset = max(socket.assigns.offset - page_size(), 0)
46
    {:noreply, socket |> assign(:offset, offset) |> load_page()}
47
  end
48
49
  defp load_page(socket) do
50
    socket
51
    |> assign(:totals, Admin.recording_totals())
52
    |> assign(:calls, Admin.list_calls(limit: page_size(), offset: socket.assigns.offset))
53
  end
54
55
  @impl true
56
  def render(assigns) do
57
    ~H"""
58
    <Layouts.app flash={@flash}>
59
      <main id="admin-page" class="app-shell admin-shell">
60
        <%!-- The same bar every other surface renders, so moving between them
61
              reads as one application. The lockup carries only the way back:
62
              nothing in the product links here, and this is not a place to
63
              navigate onward from. --%>
64
        <Layouts.command_bar aria_label="Sarah operator panel" current_user={@current_user}>
65
          <:lockup>
66
            <.button
67
              id="return-to-conversation"
68
              variant={:chip}
69
              size={:xs}
70
              phx-click={JS.navigate(~p"/chat")}
71
            >
72
              <.icon name="arrow-left" /> RETURN TO CONVERSATION
73
            </.button>
74
          </:lockup>
75
        </Layouts.command_bar>
76
77
        <section class="admin" aria-label="Voice call recordings">
78
          <header class="admin-heading">
79
            <h1>Voice calls</h1>
80
            <p>
81
              Every call across every account, newest first. Audio is captured by the
82
              caller's browser and stored {sealed_label(@recording_config)}; it is
83
              deleted {@recording_config.retention_days} days after a call ends.
84
            </p>
85
            <div class="admin-totals">
86
              <.badge variant={:dim}>{@totals.calls} calls</.badge>
87
              <.badge variant={:info}>{@totals.recorded} with audio</.badge>
88
              <.badge variant={:dim}>{format_bytes(@totals.byte_size)} stored</.badge>
89
              <.badge :if={!@recording_config.enabled?} variant={:warning}>
90
                RECORDING OFF
91
              </.badge>
92
            </div>
93
          </header>
94
95
          <.empty :if={@calls == []} id="admin-empty" title="No voice calls yet">
96
            Calls appear here as soon as anyone talks with OpenAgents.
97
          </.empty>
98
99
          <ol :if={@calls != []} id="admin-calls" class="admin-rows">
100
            <li :for={call <- @calls} id={"admin-call-#{call.session_id}"}>
101
              <.card>
102
                <div class="admin-row">
103
                  <div class="admin-identity">
104
                    <.avatar
105
                      src={call.github_avatar_url}
106
                      alt=""
107
                      size={:sm}
108
                      fallback={String.first(call.github_login)}
109
                    />
110
                    <span>
111
                      <strong :if={Call.display_name(call)}>{Call.display_name(call)}</strong>
112
                      <span>@{call.github_login}</span>
113
                    </span>
114
                  </div>
115
116
                  <div class="admin-state">
117
                    <.badge variant={status_variant(call.status)}>
118
                      {String.upcase(call.status)}
119
                    </.badge>
120
                    <.badge :if={call.termination_reason} variant={:dim}>
121
                      {call.termination_reason}
122
                    </.badge>
123
                    <.badge :if={call.failure_code} variant={:danger}>{call.failure_code}</.badge>
124
                  </div>
125
126
                  <dl class="admin-meta">
127
                    <div>
128
                      <dt>Started</dt>
129
                      <dd>{format_timestamp(call.started_at)}</dd>
130
                    </div>
131
                    <div>
132
                      <dt>Call length</dt>
133
                      <dd>{format_call_length(call)}</dd>
134
                    </div>
135
                    <div>
136
                      <dt>Model</dt>
137
                      <dd>{call.model_id}</dd>
138
                    </div>
139
                    <div>
140
                      <dt>Tokens</dt>
141
                      <dd>{format_count(call.total_tokens)}</dd>
142
                    </div>
143
                    <div>
144
                      <dt>Transcript</dt>
145
                      <dd>{format_count(call.transcript_item_count)} items</dd>
146
                    </div>
147
                    <div :if={call.recording}>
148
                      <dt>Audio</dt>
149
                      <dd>
150
                        {format_bytes(call.recording.byte_size)} · {Call.completeness(call)}
151
                      </dd>
152
                    </div>
153
                  </dl>
154
155
                  <.audio_player
156
                    :if={Call.playable?(call)}
157
                    id={"admin-audio-#{call.session_id}"}
158
                    src="#"
159
                    label={"Call with @#{call.github_login} on #{format_timestamp(call.started_at)}"}
160
                  />
161
162
                  <p :if={!Call.playable?(call)} class="admin-absence">
163
                    {Call.absence_reason(call)}
164
                  </p>
165
                </div>
166
              </.card>
167
            </li>
168
          </ol>
169
170
          <nav :if={@totals.calls > page_size()} class="admin-pager" aria-label="Call pages">
171
            <.button
172
              id="admin-previous"
173
              variant={:secondary}
174
              size={:sm}
175
              disabled={@offset == 0}
176
              phx-click="previous_page"
177
            >
178
              <.icon name="arrow-left" /> NEWER
179
            </.button>
180
            <span>{page_label(@offset, @calls, @totals.calls)}</span>
181
            <.button
182
              id="admin-next"
183
              variant={:secondary}
184
              size={:sm}
185
              disabled={@offset + page_size() >= @totals.calls}
186
              phx-click="next_page"
187
            >
188
              OLDER
189
            </.button>
190
          </nav>
191
        </section>
192
      </main>
193
    </Layouts.app>
194
    """
195
  end
196
197
  # The panel is paged rather than streamed: an operator scanning recent calls
198
  # wants a bounded page, and every row carries an audio element the browser will
199
  # ask metadata for.
200
  defp page_size, do: 25
201
202
  defp page_label(offset, calls, total) do
203
    "#{offset + 1}–#{offset + length(calls)} of #{total}"
204
  end
205
206
  defp sealed_label(%{sealed?: true}), do: "encrypted"
207
  defp sealed_label(%{sealed?: false}), do: "unencrypted"
208
209
  defp status_variant("ended"), do: :dim
210
  defp status_variant("failed"), do: :danger
211
  defp status_variant(_active), do: :success
212
213
  # The browser's own duration claim when it uploaded one, otherwise the durable
214
  # session clock. Labeled the same either way, because neither is a measurement
215
  # of the audio itself.
216
  defp format_call_length(%Call{recording: %{client_duration_ms: ms}}) when is_integer(ms),
217
    do: format_duration_ms(ms)
218
219
  defp format_call_length(%Call{started_at: started, ended_at: ended})
220
       when not is_nil(started) and not is_nil(ended),
221
       do: started |> DateTime.diff(ended, :millisecond) |> abs() |> format_duration_ms()
222
223
  defp format_call_length(%Call{}), do: "—"
224
225
  defp format_duration_ms(ms) when is_integer(ms) and ms >= 0 do
226
    total_seconds = div(ms, 1_000)
227
    minutes = div(total_seconds, 60)
228
    seconds = rem(total_seconds, 60)
229
    "#{minutes}:#{String.pad_leading(Integer.to_string(seconds), 2, "0")}"
230
  end
231
232
  defp format_timestamp(nil), do: "—"
233
234
  defp format_timestamp(%DateTime{} = at) do
235
    at
236
    |> DateTime.truncate(:second)
237
    |> Calendar.strftime("%Y-%m-%d %H:%M:%S UTC")
238
  end
239
240
  defp format_bytes(bytes) when is_integer(bytes) and bytes < 1_024, do: "#{bytes} B"
241
242
  defp format_bytes(bytes) when is_integer(bytes) and bytes < 1_048_576,
243
    do: "#{Float.round(bytes / 1_024, 1)} KB"
244
245
  defp format_bytes(bytes) when is_integer(bytes),
246
    do: "#{Float.round(bytes / 1_048_576, 1)} MB"
247
248
  defp format_bytes(_bytes), do: "0 B"
249
250
  defp format_count(count) when is_integer(count) do
251
    count
252
    |> Integer.to_string()
253
    |> String.reverse()
254
    |> String.replace(~r/(\d{3})(?=\d)/, "\\1,")
255
    |> String.reverse()
256
  end
257
end
lib/openagents_web/live/changelog_live.ex added +217

@@ -0,0 +1,217 @@

1
defmodule OpenAgentsWeb.ChangelogLive do
2
  @moduledoc """
3
  The public changelog (#138, spec §3): two layers over one timeline — a
4
  human note per change, and an expandable agent layer with the receipt
5
  chain (sha, modules, measured push→live, deploy result, provenance).
6
7
  Same public posture as the leaderboard and status pages (UI-001 lineage,
8
  TRANSPARENCY-001): read-only, mounts without a session, cannot invoke
9
  Sarah, renders only `OpenAgentsWeb.UI` primitives (UI-003), and updates live
10
  off the forge PubSub — a hot-load appears here seconds after it lands.
11
  """
12
13
  use OpenAgentsWeb, :sarah_live_view
14
15
  alias OpenAgents.Changelog
16
17
  @repo "sarah"
18
  @forge_topics ["forge:pushes", "forge:target", "forge:builds", "forge:deploys"]
19
20
  @impl true
21
  def mount(_params, _session, socket) do
22
    rows =
23
      case Changelog.timeline(@repo) do
24
        {:ok, rows} -> rows
25
        {:error, :not_public} -> raise OpenAgentsWeb.PublicNotFoundError
26
      end
27
28
    if connected?(socket) do
29
      Enum.each(@forge_topics, &Phoenix.PubSub.subscribe(OpenAgents.PubSub, &1))
30
    end
31
32
    {:ok,
33
     socket
34
     |> assign(:page_title, "Changelog · Sarah")
35
     |> assign(:repo, @repo)
36
     |> assign(:base, OpenAgents.Forge.Visibility.repo_path(@repo) || "")
37
     |> assign(:rows, rows)
38
     |> assign(:filter, nil)}
39
  end
40
41
  @impl true
42
  def handle_event("filter", %{"category" => category}, socket) do
43
    filter = if category == socket.assigns.filter, do: nil, else: category
44
    {:noreply, assign(socket, :filter, filter)}
45
  end
46
47
  @impl true
48
  def handle_info(message, socket)
49
      when elem(message, 0) in [
50
             :forge_push,
51
             :forge_target,
52
             :forge_target_status,
53
             :forge_build_ready,
54
             :forge_deploy
55
           ] do
56
    case Changelog.timeline(@repo, refresh: true) do
57
      {:ok, rows} -> {:noreply, assign(socket, :rows, rows)}
58
      _ -> {:noreply, socket}
59
    end
60
  end
61
62
  def handle_info(_message, socket), do: {:noreply, socket}
63
64
  defp visible_rows(rows, nil), do: rows
65
  defp visible_rows(rows, filter), do: Enum.filter(rows, &(&1.category == filter))
66
67
  defp categories_present(rows) do
68
    rows |> Enum.map(& &1.category) |> Enum.uniq() |> Enum.sort()
69
  end
70
71
  defp category_variant("ui"), do: :info
72
  defp category_variant("fix"), do: :warning
73
  defp category_variant("incident"), do: :danger
74
  defp category_variant("forge"), do: :success
75
  defp category_variant(_), do: :default
76
77
  defp result_variant("live"), do: :success
78
  defp result_variant("reverted"), do: :danger
79
  defp result_variant("failed"), do: :danger
80
  defp result_variant(_), do: :warning
81
82
  defp stamp(nil), do: "—"
83
84
  defp stamp(%DateTime{} = at), do: Calendar.strftime(at, "%Y-%m-%d %H:%M UTC")
85
86
  defp ms_text(nil), do: "—"
87
  defp ms_text(ms) when ms < 1_000, do: "#{ms} ms"
88
  defp ms_text(ms), do: "#{Float.round(ms / 1_000, 1)} s"
89
90
  @impl true
91
  def render(assigns) do
92
    ~H"""
93
    <Layouts.app flash={@flash}>
94
      <main id="changelog-page" class="app-shell changelog-shell">
95
        <Layouts.command_bar aria_label="Sarah changelog" current_user={@current_user}>
96
          <:lockup>
97
            <.button
98
              :if={@current_user}
99
              id="return-to-conversation"
100
              variant={:chip}
101
              size={:xs}
102
              phx-click={JS.navigate(~p"/chat")}
103
            >
104
              <.icon name="arrow-left" /> RETURN TO CONVERSATION
105
            </.button>
106
          </:lockup>
107
        </Layouts.command_bar>
108
109
        <section class="changelog" aria-label="Changelog">
110
          <header class="changelog-heading">
111
            <div>
112
              <h1>Changelog</h1>
113
              <p>
114
                Every change to Sarah, two layers deep: what changed in plain
115
                words, and — expand any entry — the receipts: the commit, the
116
                modules hot-loaded, and the measured push→live time. Sarah
117
                ships her own code through her own forge; this page is how
118
                you watch her do it.
119
              </p>
120
            </div>
121
          </header>
122
123
          <div class="changelog-filters" role="group" aria-label="Filter by category">
124
            <.button
125
              :for={category <- categories_present(@rows)}
126
              id={"changelog-filter-#{category}"}
127
              variant={:chip}
128
              size={:xs}
129
              phx-click="filter"
130
              phx-value-category={category}
131
              aria-pressed={to_string(@filter == category)}
132
            >
133
              {category}
134
            </.button>
135
          </div>
136
137
          <.empty :if={@rows == []} id="changelog-empty" title="Nothing yet">
138
            The first receipted change will appear here as it ships.
139
          </.empty>
140
141
          <ol class="changelog-rows" aria-label="Changes, newest first">
142
            <li :for={{row, index} <- Enum.with_index(visible_rows(@rows, @filter))}>
143
              <.card id={"changelog-row-#{index}"}>
144
                <div class="changelog-row__head">
145
                  <.badge variant={category_variant(row.category)}>{row.category}</.badge>
146
                  <span class="changelog-row__stamp">{stamp(row.entry_at)}</span>
147
                  <.badge :if={row.deploy} variant={result_variant(row.deploy.result)}>
148
                    {row.deploy.result}
149
                  </.badge>
150
                </div>
151
152
                <p :if={row.summary} class="changelog-row__summary">{row.summary}</p>
153
                <p
154
                  :if={is_nil(row.summary)}
155
                  class="changelog-row__summary changelog-row__summary--bare"
156
                >
157
                  Receipted deploy of <code>{row.short_sha}</code> — no release note was written.
158
                </p>
159
160
                <details class="changelog-row__detail">
161
                  <summary>detail</summary>
162
                  <dl class="changelog-facts">
163
                    <div :if={row.short_sha}>
164
                      <dt>commit</dt>
165
                      <dd>
166
                        <.text_button navigate={"#{@base}/commit/#{row.short_sha}"}>
167
                          <code>{row.short_sha}</code>
168
                        </.text_button>
169
                      </dd>
170
                    </div>
171
                    <div :if={row.deploy}>
172
                      <dt>push→live</dt>
173
                      <dd>{ms_text(row.deploy.push_to_live_ms)}</dd>
174
                    </div>
175
                    <div :if={row.deploy}>
176
                      <dt>modules</dt>
177
                      <dd>{row.deploy.modules} on {row.deploy.nodes} nodes</dd>
178
                    </div>
179
                    <div :if={row.push}>
180
                      <dt>pushed by</dt>
181
                      <dd>{row.push.principal_role} (wal seq {row.push.wal_seq})</dd>
182
                    </div>
183
                    <div :if={row.build}>
184
                      <dt>build</dt>
185
                      <dd>{ms_text(row.build.duration_ms)}, {row.build.modules} modules</dd>
186
                    </div>
187
                    <div>
188
                      <dt>source</dt>
189
                      <dd>{row.source}</dd>
190
                    </div>
191
                    <div :if={row.trace_digest}>
192
                      <dt>trace digest</dt>
193
                      <dd><code>{row.trace_digest}</code></dd>
194
                    </div>
195
                  </dl>
196
                </details>
197
              </.card>
198
            </li>
199
          </ol>
200
201
          <footer class="changelog-footer">
202
            <p>
203
              Machine-readable: <code>GET /api/changelog</code>
204
              (schema <code>{Changelog.schema_version()}</code>) ·
205
              <.text_button navigate={"#{@base}/blob/main/CHANGELOG.md"}>
206
                this changelog as a document
207
              </.text_button>
208
              · <.text_button navigate="/status">fleet status</.text_button>.
209
              Entries are projections; the receipt chain is the authority.
210
            </p>
211
          </footer>
212
        </section>
213
      </main>
214
    </Layouts.app>
215
    """
216
  end
217
end
lib/openagents_web/live/code_blob_live.ex added +163

@@ -0,0 +1,163 @@

1
defmodule OpenAgentsWeb.CodeBlobLive do
2
  @moduledoc """
3
  The public file view (#136): a stable, shareable openagents.com URL for
4
  any file at any ref on a forge repo —
5
  `/:owner/:repo/blob/:ref/*path`. Branch URLs are living (they follow the
6
  branch); sha-pinned URLs are permanent.
7
8
  Served straight from the bare repo through `OpenAgents.Forge.Browse` (bounded
9
  git plumbing, WAL-fresh), gated by the repo's disclosure level
10
  (`OpenAgents.Forge.Visibility`, TRANSPARENCY-001 — files need :l3). Markdown
11
  renders through `OpenAgents.Markdown`'s allowlist walk; everything else is an
12
  escaped code block; binaries are named, never rendered. Public posture as
13
  the leaderboard: read-only, no session required, cannot invoke OpenAgents.
14
  """
15
16
  use OpenAgentsWeb, :sarah_live_view
17
18
  alias OpenAgents.Forge.{Browse, Visibility}
19
20
  @impl true
21
  def mount(%{"repo" => repo, "ref" => ref} = params, _session, socket) do
22
    path = params |> Map.get("path", []) |> Enum.join("/")
23
    base = Visibility.repo_path(repo)
24
25
    unless OpenAgents.Forge.enabled?() and base do
26
      raise OpenAgentsWeb.PublicNotFoundError
27
    end
28
29
    sha =
30
      case Browse.resolve_commit(repo, ref) do
31
        {:ok, sha} -> sha
32
        _ -> raise OpenAgentsWeb.PublicNotFoundError
33
      end
34
35
    # Either the whole repo is browsable (:l3), or this is a published
36
    # document at the current head. A published path at an older ref is a
37
    # 404: publishing one document must not publish its history.
38
    head = with {:ok, head} <- Browse.head(repo), do: head
39
40
    unless Visibility.allows_file?(repo, path, sha, head) do
41
      raise OpenAgentsWeb.PublicNotFoundError
42
    end
43
44
    blob =
45
      case Browse.blob(repo, sha, path) do
46
        {:ok, blob} -> blob
47
        _ -> raise OpenAgentsWeb.PublicNotFoundError
48
      end
49
50
    plain = Map.get(params, "plain") == "1"
51
52
    {:ok,
53
     socket
54
     |> assign(:page_title, "#{Path.basename(path)} · #{repo}")
55
     |> assign(:repo, repo)
56
     |> assign(:owner, Visibility.owner(repo))
57
     |> assign(:base, base)
58
     |> assign(:ref, ref)
59
     |> assign(:sha, sha)
60
     |> assign(:path, path)
61
     |> assign(:blob, blob)
62
     |> assign(:browsable, Visibility.allows?(repo, :files))
63
     |> assign(:markdown?, markdown?(path) and not plain and not blob.binary)}
64
  end
65
66
  defp markdown?(path), do: String.ends_with?(String.downcase(path), [".md", ".markdown"])
67
68
  defp short(sha), do: String.slice(sha, 0, 12)
69
70
  defp size_text(nil), do: "—"
71
  defp size_text(bytes) when bytes < 1_024, do: "#{bytes} B"
72
  defp size_text(bytes) when bytes < 1_048_576, do: "#{div(bytes, 1_024)} KB"
73
  defp size_text(bytes), do: "#{Float.round(bytes / 1_048_576, 1)} MB"
74
75
  @impl true
76
  def render(assigns) do
77
    ~H"""
78
    <Layouts.app flash={@flash}>
79
      <main id="code-blob-page" class="app-shell code-shell">
80
        <Layouts.command_bar aria_label="Sarah code" current_user={@current_user}>
81
          <:lockup>
82
            <.button
83
              id="code-back-to-changelog"
84
              variant={:chip}
85
              size={:xs}
86
              phx-click={JS.navigate("/changelog")}
87
            >
88
              <.icon name="arrow-left" /> CHANGELOG
89
            </.button>
90
          </:lockup>
91
        </Layouts.command_bar>
92
93
        <section class="code" aria-label="File view">
94
          <header class="code-heading">
95
            <div>
96
              <h1 class="code-path">{@path}</h1>
97
              <p class="code-meta">
98
                <code>{@ref}</code>
99
                <span :if={@ref != short(@sha)}>
100
                  at
101
                  <.text_button navigate={"#{@base}/commit/#{short(@sha)}"}>
102
                    <code>{short(@sha)}</code>
103
                  </.text_button>
104
                </span>
105
                · {size_text(@blob.size)}
106
                <span :if={@blob.truncated}>· truncated for display</span>
107
              </p>
108
            </div>
109
            <div class="code-actions">
110
              <.button
111
                :if={markdown?(@path) and not @blob.binary}
112
                id="code-plain-toggle"
113
                variant={:chip}
114
                size={:xs}
115
                phx-click={
116
                  JS.navigate(
117
                    "#{@base}/blob/#{@ref}/#{@path}" <>
118
                      if(@markdown?, do: "?plain=1", else: "")
119
                  )
120
                }
121
              >
122
                {if @markdown?, do: "VIEW SOURCE", else: "VIEW RENDERED"}
123
              </.button>
124
            </div>
125
          </header>
126
127
          <.alert :if={@blob.truncated} id="code-truncated" variant={:warning}>
128
            This file is larger than the display bound; the tail is cut. The
129
            full file is in the repository itself.
130
          </.alert>
131
132
          <.card id="code-blob">
133
            <div :if={@markdown?} class="code-markdown">
134
              {OpenAgents.Markdown.to_html(@blob.content)}
135
            </div>
136
            <pre :if={not @markdown? and not @blob.binary} class="code-source"><code>{@blob.content}</code></pre>
137
            <.empty :if={@blob.binary} id="code-binary" title="Binary file">
138
              {size_text(@blob.size)} of binary content — not rendered.
139
            </.empty>
140
          </.card>
141
142
          <footer class="code-footer">
143
            <p :if={@browsable}>
144
              A branch URL follows the branch; this exact revision is
145
              permanent at
146
              <.text_button navigate={"#{@base}/blob/#{short(@sha)}/#{@path}"}>
147
                {@base}/blob/{short(@sha)}/{@path}
148
              </.text_button>
149
            </p>
150
            <p :if={!@browsable}>
151
              A published document from a private repository. It is served at
152
              the current revision only — earlier revisions are not public —
153
              and the rest of the source is not browsable. See
154
              <.text_button navigate="/changelog">the changelog</.text_button>
155
              for what has changed and when.
156
            </p>
157
          </footer>
158
        </section>
159
      </main>
160
    </Layouts.app>
161
    """
162
  end
163
end
lib/openagents_web/live/code_commit_live.ex added +254

@@ -0,0 +1,254 @@

1
defmodule OpenAgentsWeb.CodeCommitLive do
2
  @moduledoc """
3
  The public commit view (#137): message, provenance trailers, changed
4
  files, the diff — and the commit's **deploy story**, the receipt chain
5
  joined from `OpenAgents.Forge.receipts_for/2`: pushed (role, WAL seq),
6
  promoted, built (modules), and deployed (result, canary, measured
7
  push→live). A sha with no receipts says so honestly.
8
9
  Disclosure per TRANSPARENCY-001: the page needs :ledger; the diff body
10
  additionally needs :diffs. Live over the forge PubSub, so a promote made
11
  while the page is open completes its story in place. Public posture as
12
  the other projections: read-only, sessionless, cannot invoke OpenAgents.
13
  """
14
15
  use OpenAgentsWeb, :sarah_live_view
16
17
  alias OpenAgents.Forge
18
  alias OpenAgents.Forge.{Browse, Visibility}
19
20
  @impl true
21
  def mount(%{"repo" => repo, "sha" => sha}, _session, socket) do
22
    base = Visibility.repo_path(repo)
23
24
    unless Visibility.allows?(repo, :ledger) and Forge.enabled?() and base do
25
      raise OpenAgentsWeb.PublicNotFoundError
26
    end
27
28
    commit =
29
      case Browse.commit(repo, sha) do
30
        {:ok, commit} -> commit
31
        _ -> raise OpenAgentsWeb.PublicNotFoundError
32
      end
33
34
    files =
35
      case Browse.changed_files(repo, commit.sha) do
36
        {:ok, files} -> files
37
        _ -> []
38
      end
39
40
    {diff, diff_truncated} =
41
      if Visibility.allows?(repo, :diffs) do
42
        case Browse.diff(repo, commit.sha) do
43
          {:ok, diff, truncated} -> {diff, truncated}
44
          _ -> {nil, false}
45
        end
46
      else
47
        {nil, false}
48
      end
49
50
    if connected?(socket) do
51
      Enum.each(
52
        ["forge:target", "forge:deploys"],
53
        &Phoenix.PubSub.subscribe(OpenAgents.PubSub, &1)
54
      )
55
    end
56
57
    {:ok,
58
     socket
59
     |> assign(:page_title, "#{short(commit.sha)} · #{repo}")
60
     |> assign(:repo, repo)
61
     |> assign(:owner, Visibility.owner(repo))
62
     |> assign(:base, base)
63
     |> assign(:commit, commit)
64
     |> assign(:files, files)
65
     |> assign(:diff, diff)
66
     |> assign(:diff_truncated, diff_truncated)
67
     |> assign(:receipts, Forge.receipts_for(repo, commit.sha))}
68
  end
69
70
  @impl true
71
  def handle_info({event, %{sha: sha}}, socket)
72
      when event in [:forge_target, :forge_target_status, :forge_deploy] do
73
    if Forge.sha_match?(sha, socket.assigns.commit.sha) do
74
      {:noreply,
75
       assign(
76
         socket,
77
         :receipts,
78
         Forge.receipts_for(socket.assigns.repo, socket.assigns.commit.sha)
79
       )}
80
    else
81
      {:noreply, socket}
82
    end
83
  end
84
85
  def handle_info(_message, socket), do: {:noreply, socket}
86
87
  defp short(sha), do: String.slice(sha, 0, 12)
88
89
  # Only the role prefix of a push principal is ever published.
90
  defp role_of(principal), do: principal |> to_string() |> String.split(":", parts: 2) |> hd()
91
92
  defp ms_text(nil), do: "—"
93
  defp ms_text(ms) when ms < 1_000, do: "#{ms} ms"
94
  defp ms_text(ms), do: "#{Float.round(ms / 1_000, 1)} s"
95
96
  defp module_text(1), do: "1 module"
97
  defp module_text(count), do: "#{count} modules"
98
99
  defp result_variant("live"), do: :success
100
  defp result_variant("reverted"), do: :danger
101
  defp result_variant("failed"), do: :danger
102
  defp result_variant(_), do: :warning
103
104
  defp status_variant("live"), do: :success
105
  defp status_variant(status) when status in ["failed", "reverted"], do: :danger
106
  defp status_variant("needs_rolling_replace"), do: :warning
107
  defp status_variant(_), do: :info
108
109
  defp trailer_link?({"Claude-Session", value}), do: String.starts_with?(value, "https://")
110
  defp trailer_link?(_), do: false
111
112
  defp file_status("A"), do: "added"
113
  defp file_status("M"), do: "modified"
114
  defp file_status("D"), do: "deleted"
115
  defp file_status("R" <> _), do: "renamed"
116
  defp file_status(other), do: other
117
118
  defp no_receipts?(receipts) do
119
    receipts.pushes == [] and receipts.targets == [] and receipts.deploys == [] and
120
      receipts.builds == []
121
  end
122
123
  @impl true
124
  def render(assigns) do
125
    ~H"""
126
    <Layouts.app flash={@flash}>
127
      <main id="code-commit-page" class="app-shell code-shell">
128
        <Layouts.command_bar aria_label="Sarah code" current_user={@current_user}>
129
          <:lockup>
130
            <.button
131
              id="code-back-to-repo"
132
              variant={:chip}
133
              size={:xs}
134
              phx-click={JS.navigate(@base)}
135
            >
136
              <.icon name="arrow-left" /> {@repo}
137
            </.button>
138
          </:lockup>
139
        </Layouts.command_bar>
140
141
        <section class="code" aria-label="Commit view">
142
          <header class="code-heading">
143
            <div>
144
              <h1 class="code-commit-subject">{@commit.subject}</h1>
145
              <p class="code-meta">
146
                <code>{short(@commit.sha)}</code>
147
                · {@commit.author} · {@commit.committed_at}
148
                <span :for={parent <- @commit.parents}>
149
                  · parent
150
                  <.text_button navigate={"#{@base}/commit/#{short(parent)}"}>
151
                    <code>{short(parent)}</code>
152
                  </.text_button>
153
                </span>
154
              </p>
155
            </div>
156
          </header>
157
158
          <.card id="commit-message">
159
            <pre class="code-message">{@commit.message}</pre>
160
            <dl :if={@commit.trailers != []} class="code-trailers">
161
              <div :for={{key, value} <- @commit.trailers}>
162
                <dt>{key}</dt>
163
                <dd :if={trailer_link?({key, value})}>
164
                  <.text_button href={value}>{value}</.text_button>
165
                </dd>
166
                <dd :if={!trailer_link?({key, value})}>{value}</dd>
167
              </div>
168
            </dl>
169
          </.card>
170
171
          <.card id="commit-deploy-story">
172
            <h2>Deploy story</h2>
173
            <p class="code-intro">
174
              What this commit did to the running system — joined from the
175
              forge receipt chain, the part a commit page elsewhere cannot
176
              show.
177
            </p>
178
179
            <.empty
180
              :if={no_receipts?(@receipts)}
181
              id="commit-no-receipts"
182
              title="Not deployed through the forge lane"
183
            >
184
              No push, promotion, build, or deploy receipt references this
185
              commit (receipts are scanned over a bounded recent window).
186
              Changes shipped by full node replacement carry their proof in
187
              the release gate receipt instead.
188
            </.empty>
189
190
            <dl :if={!no_receipts?(@receipts)} class="code-receipts">
191
              <div :for={push <- @receipts.pushes}>
192
                <dt>pushed</dt>
193
                <dd>
194
                  by {role_of(push.principal)} · WAL seq {push.wal_seq} · {push.inserted_at}
195
                </dd>
196
              </div>
197
              <div :for={target <- @receipts.targets}>
198
                <dt>promoted</dt>
199
                <dd>
200
                  by {role_of(target.promoted_by)} ·
201
                  <.badge variant={status_variant(target.status)}>{target.status}</.badge>
202
                  · {target.inserted_at}
203
                </dd>
204
              </div>
205
              <div :for={build <- @receipts.builds}>
206
                <dt>built</dt>
207
                <dd>
208
                  {length(build.modules || [])} modules in {ms_text(build.duration_ms)}
209
                </dd>
210
              </div>
211
              <div :for={deploy <- @receipts.deploys}>
212
                <dt>deployed</dt>
213
                <dd>
214
                  <.badge variant={result_variant(deploy.result)}>{deploy.result}</.badge>
215
                  · {module_text(length(deploy.modules || []))} on {length(deploy.nodes || [])} nodes
216
                  · push→live {ms_text(deploy.push_to_live_ms)}
217
                </dd>
218
              </div>
219
            </dl>
220
          </.card>
221
222
          <.card id="commit-files">
223
            <h2>Changed files</h2>
224
            <.empty :if={@files == []} id="commit-no-files" title="No file changes">
225
              An empty or merge commit.
226
            </.empty>
227
            <ul :if={@files != []} class="code-files">
228
              <li :for={file <- @files}>
229
                <.badge variant={:dim}>{file_status(file.status)}</.badge>
230
                <code>{file.path}</code>
231
              </li>
232
            </ul>
233
          </.card>
234
235
          <.card :if={@diff} id="commit-diff">
236
            <h2>Diff</h2>
237
            <.alert :if={@diff_truncated} id="commit-diff-truncated" variant={:warning}>
238
              The diff is larger than the display bound; the tail is cut.
239
            </.alert>
240
            <pre class="code-source code-diff"><code>{@diff}</code></pre>
241
          </.card>
242
243
          <footer class="code-footer">
244
            <p>
245
              This page updates live while a promote is in flight ·
246
              <.text_button navigate="/changelog">changelog</.text_button>
247
            </p>
248
          </footer>
249
        </section>
250
      </main>
251
    </Layouts.app>
252
    """
253
  end
254
end
lib/openagents_web/live/code_repo_live.ex added +123

@@ -0,0 +1,123 @@

1
defmodule OpenAgentsWeb.CodeRepoLive do
2
  @moduledoc """
3
  The public repo home (#136): README render, the latest commits, and the
4
  ref list for a forge repo at `/:owner/:repo`. Gated by the repo's
5
  disclosure level (TRANSPARENCY-001 — the page needs :files since it
6
  renders the README). Same public read-only posture as the other
7
  projections; data comes from `OpenAgents.Forge.Browse`'s bounded plumbing.
8
  """
9
10
  use OpenAgentsWeb, :sarah_live_view
11
12
  alias OpenAgents.Forge
13
  alias OpenAgents.Forge.{Browse, Visibility}
14
15
  @impl true
16
  def mount(%{"repo" => repo}, _session, socket) do
17
    base = Visibility.repo_path(repo)
18
19
    unless Visibility.allows?(repo, :files) and Forge.enabled?() and base do
20
      raise OpenAgentsWeb.PublicNotFoundError
21
    end
22
23
    head =
24
      case Browse.head(repo) do
25
        {:ok, sha} -> sha
26
        _ -> raise OpenAgentsWeb.PublicNotFoundError
27
      end
28
29
    readme =
30
      case Browse.readme(repo, head) do
31
        {:ok, name, blob} -> %{name: name, blob: blob}
32
        _ -> nil
33
      end
34
35
    commits =
36
      case Browse.log(repo, head, 20) do
37
        {:ok, commits} -> commits
38
        _ -> []
39
      end
40
41
    {:ok,
42
     socket
43
     |> assign(:page_title, "#{repo} · code")
44
     |> assign(:repo, repo)
45
     |> assign(:owner, Visibility.owner(repo))
46
     |> assign(:base, base)
47
     |> assign(:head, head)
48
     |> assign(:readme, readme)
49
     |> assign(:commits, commits)
50
     |> assign(:refs, Browse.refs(repo))}
51
  end
52
53
  defp short(sha), do: String.slice(sha, 0, 12)
54
55
  @impl true
56
  def render(assigns) do
57
    ~H"""
58
    <Layouts.app flash={@flash}>
59
      <main id="code-repo-page" class="app-shell code-shell">
60
        <Layouts.command_bar aria_label="Sarah code" current_user={@current_user}>
61
          <:lockup>
62
            <.button
63
              :if={@current_user}
64
              id="return-to-conversation"
65
              variant={:chip}
66
              size={:xs}
67
              phx-click={JS.navigate(~p"/chat")}
68
            >
69
              <.icon name="arrow-left" /> RETURN TO CONVERSATION
70
            </.button>
71
          </:lockup>
72
        </Layouts.command_bar>
73
74
        <section class="code" aria-label="Repository">
75
          <header class="code-heading">
76
            <div>
77
              <h1>{@owner}/{@repo}</h1>
78
              <p class="code-meta">
79
                head <code>{short(@head)}</code>
80
                · <.text_button navigate="/changelog">changelog</.text_button>
81
                · read-only mirror for anonymous clones: <code>github.com/OpenAgentsInc/{@repo}</code>
82
              </p>
83
            </div>
84
          </header>
85
86
          <.card id="repo-commits">
87
            <h2>Recent commits</h2>
88
            <ol class="code-commits">
89
              <li :for={commit <- @commits}>
90
                <.text_button navigate={"#{@base}/commit/#{short(commit.sha)}"}>
91
                  <code>{short(commit.sha)}</code>
92
                </.text_button>
93
                <span class="code-commit-line">{commit.subject}</span>
94
                <span class="code-commit-author">{commit.author}</span>
95
              </li>
96
            </ol>
97
          </.card>
98
99
          <.card id="repo-refs">
100
            <h2>Refs</h2>
101
            <ul class="code-refs">
102
              <li :for={ref <- @refs}>
103
                <.badge variant={:dim}>{ref.kind}</.badge>
104
                {ref.name}
105
                <.text_button navigate={"#{@base}/commit/#{short(ref.sha)}"}>
106
                  <code>{short(ref.sha)}</code>
107
                </.text_button>
108
              </li>
109
            </ul>
110
          </.card>
111
112
          <.card :if={@readme} id="repo-readme">
113
            <h2>{@readme.name}</h2>
114
            <div class="code-markdown">
115
              {OpenAgents.Markdown.to_html(@readme.blob.content)}
116
            </div>
117
          </.card>
118
        </section>
119
      </main>
120
    </Layouts.app>
121
    """
122
  end
123
end
lib/openagents_web/live/computers_live.ex added +414

@@ -0,0 +1,414 @@

1
defmodule OpenAgentsWeb.ComputersLive do
2
  @moduledoc """
3
  Approve controller pairing codes and manage this account's paired computers.
4
  """
5
6
  use OpenAgentsWeb, :sarah_live_view
7
8
  alias OpenAgents.Computer
9
  alias OpenAgents.Machines
10
  alias OpenAgents.Machines.Machine
11
12
  @presence_refresh_ms 15_000
13
14
  @impl true
15
  def mount(_params, _session, socket) do
16
    socket =
17
      socket
18
      |> stream_configure(:machines, dom_id: &"computer-#{&1.id}")
19
      |> assign(:page_title, "Computers · Sarah")
20
      |> assign(:controller_enabled?, Computer.enabled?())
21
      |> assign(:pairing_form, to_form(%{"code" => ""}, as: :pairing))
22
      |> assign(:pairing_error, nil)
23
      |> assign(:operation_success, nil)
24
      |> assign(:subscribed_machine_ids, MapSet.new())
25
      |> assign(:presence, %{})
26
      |> assign(:machine_count, 0)
27
      |> load_machines()
28
29
    if connected?(socket), do: schedule_presence_refresh()
30
    {:ok, socket}
31
  end
32
33
  @impl true
34
  def handle_event("approve_pairing", _params, %{assigns: %{controller_enabled?: false}} = socket) do
35
    {:noreply,
36
     socket
37
     |> assign(:operation_success, nil)
38
     |> assign(:pairing_error, "Computer pairing is currently unavailable.")}
39
  end
40
41
  def handle_event("approve_pairing", %{"pairing" => %{"code" => code}}, socket) do
42
    case Machines.approve_pairing(socket.assigns.current_user, code) do
43
      {:ok, machine} ->
44
        {:noreply,
45
         socket
46
         |> assign(:pairing_error, nil)
47
         |> assign(:operation_success, %{
48
           id: "pairing-success",
49
           label: "PAIRED",
50
           message:
51
             "Computer \"#{machine.name}\" paired. It will appear online after the controller connects."
52
         })
53
         |> assign(:pairing_form, to_form(%{"code" => ""}, as: :pairing))
54
         |> load_machines()}
55
56
      {:error, reason} ->
57
        {:noreply,
58
         socket
59
         |> assign(:operation_success, nil)
60
         |> assign(:pairing_error, pairing_error(reason))}
61
    end
62
  end
63
64
  def handle_event("revoke_machine", %{"id" => machine_id}, socket) do
65
    case Machines.revoke_machine(socket.assigns.current_user, machine_id) do
66
      {:ok, machine} ->
67
        {:noreply,
68
         socket
69
         |> assign(:operation_success, %{
70
           id: "revocation-success",
71
           label: "REVOKED",
72
           message:
73
             "Access for \"#{machine.name}\" was revoked. Any active connection was closed."
74
         })
75
         |> assign(:pairing_error, nil)
76
         |> load_machines()}
77
78
      {:error, _reason} ->
79
        {:noreply,
80
         socket
81
         |> assign(:operation_success, nil)
82
         |> assign(:pairing_error, "Computer not found.")}
83
    end
84
  end
85
86
  @impl true
87
  def handle_info({:computer_presence, machine_id, presence}, socket)
88
      when presence in [:online, :offline] do
89
    case Machines.get_machine(socket.assigns.current_user.id, machine_id) do
90
      {:ok, machine} ->
91
        presence_map = Map.put(socket.assigns.presence, machine_id, presence == :online)
92
93
        {:noreply,
94
         socket
95
         |> assign(:presence, presence_map)
96
         |> stream_insert(:machines, machine)}
97
98
      {:error, :machine_not_found} ->
99
        {:noreply, socket}
100
    end
101
  end
102
103
  def handle_info(
104
        {:machine_updated, %Machine{user_id: user_id} = machine},
105
        %{assigns: %{current_user: %{id: user_id}}} = socket
106
      ) do
107
    presence_map = Map.put(socket.assigns.presence, machine.id, Computer.online?(machine.id))
108
109
    {:noreply,
110
     socket
111
     |> assign(:presence, presence_map)
112
     |> stream_insert(:machines, machine)}
113
  end
114
115
  def handle_info({:machine_updated, %Machine{}}, socket), do: {:noreply, socket}
116
117
  def handle_info({:machine_revoked, machine_id}, socket) do
118
    case Machines.get_machine(socket.assigns.current_user.id, machine_id) do
119
      {:ok, machine} ->
120
        presence_map = Map.put(socket.assigns.presence, machine.id, false)
121
122
        {:noreply,
123
         socket
124
         |> assign(:presence, presence_map)
125
         |> stream_insert(:machines, machine)}
126
127
      {:error, :machine_not_found} ->
128
        {:noreply, socket}
129
    end
130
  end
131
132
  def handle_info(:refresh_computer_presence, socket) do
133
    schedule_presence_refresh()
134
    {:noreply, load_machines(socket)}
135
  end
136
137
  def handle_info(_message, socket), do: {:noreply, socket}
138
139
  defp load_machines(socket) do
140
    machines = Machines.list_machines(socket.assigns.current_user.id)
141
    socket = subscribe_to_machines(socket, machines)
142
143
    presence =
144
      Map.new(machines, fn machine ->
145
        {machine.id, machine.status == "active" and Computer.online?(machine.id)}
146
      end)
147
148
    socket
149
    |> assign(:presence, presence)
150
    |> assign(:machine_count, length(machines))
151
    |> stream(:machines, machines, reset: true)
152
  end
153
154
  defp subscribe_to_machines(socket, machines) do
155
    if connected?(socket) do
156
      subscribed = socket.assigns.subscribed_machine_ids
157
      machine_ids = MapSet.new(machines, & &1.id)
158
159
      machine_ids
160
      |> MapSet.difference(subscribed)
161
      |> Enum.each(&Computer.subscribe/1)
162
163
      assign(socket, :subscribed_machine_ids, MapSet.union(subscribed, machine_ids))
164
    else
165
      socket
166
    end
167
  end
168
169
  defp schedule_presence_refresh do
170
    Process.send_after(self(), :refresh_computer_presence, @presence_refresh_ms)
171
  end
172
173
  defp pairing_error(:pairing_not_found), do: "No pairing found for that code."
174
  defp pairing_error(:pairing_expired), do: "That pairing code expired. Start over on the CLI."
175
  defp pairing_error(:pairing_consumed), do: "That pairing code was already used."
176
  defp pairing_error(:too_many_machines), do: "Computer limit reached. Revoke one to free a slot."
177
  defp pairing_error(_reason), do: "Pairing failed."
178
179
  defp online?(machine, presence) do
180
    machine.status == "active" and Map.get(presence, machine.id, false)
181
  end
182
183
  defp credential_variant("active"), do: :success
184
  defp credential_variant(_status), do: :warning
185
186
  defp policy_label(tier), do: String.upcase(tier) <> " POLICY"
187
188
  defp root_label([]), do: "No approved roots"
189
  defp root_label([_root]), do: "1 approved root"
190
  defp root_label(roots), do: "#{length(roots)} approved roots"
191
192
  @impl true
193
  def render(assigns) do
194
    ~H"""
195
    <Layouts.app flash={@flash}>
196
      <main id="computers-page" class="app-shell computers-shell">
197
        <Layouts.command_bar aria_label="Sarah computers" current_user={@current_user}>
198
          <:lockup>
199
            <.button
200
              id="return-to-conversation"
201
              variant={:chip}
202
              size={:xs}
203
              phx-click={JS.navigate(~p"/chat")}
204
            >
205
              <.icon name="arrow-left" /> RETURN TO CONVERSATION
206
            </.button>
207
          </:lockup>
208
        </Layouts.command_bar>
209
210
        <section id="computers-manager" class="computers" aria-label="Paired computers">
211
          <div class="computers__inner">
212
            <header class="computers-heading">
213
              <span class="computers-heading__kicker">YOUR DEVICES</span>
214
              <h1>Computers</h1>
215
              <p>
216
                Give Sarah a governed connection to computers you control. Pairing authorizes
217
                the declared policy; live presence tells you whether that controller is reachable now.
218
              </p>
219
            </header>
220
221
            <.card :if={@controller_enabled?} id="pairing-card" class="computers-pairing">
222
              <div class="computers-pairing__intro">
223
                <span class="computers-pairing__icon"><.icon name="terminal-lg" /></span>
224
                <div>
225
                  <h2>Pair a computer</h2>
226
                  <p>
227
                    Run <code>sarah-computer-controller pair</code> on the computer, then enter
228
                    its one-time code. Codes expire after ten minutes.
229
                  </p>
230
                </div>
231
              </div>
232
233
              <.form
234
                for={@pairing_form}
235
                id="pairing-form"
236
                class="computers-pairing__form"
237
                phx-submit="approve_pairing"
238
              >
239
                <.field class="computers-pairing__field">
240
                  <.label for={@pairing_form[:code].id}>Pairing code</.label>
241
                  <.input
242
                    id={@pairing_form[:code].id}
243
                    name={@pairing_form[:code].name}
244
                    value={@pairing_form[:code].value}
245
                    placeholder="ABCD-EFGH"
246
                    autocomplete="off"
247
                    maxlength="9"
248
                    required
249
                  />
250
                </.field>
251
                <.button id="approve-pairing" type="submit" phx-disable-with="Pairing…">
252
                  Approve pairing
253
                </.button>
254
              </.form>
255
            </.card>
256
257
            <.alert
258
              :if={!@controller_enabled?}
259
              id="computer-controller-disabled"
260
              variant={:warning}
261
              appearance={:notice}
262
              label="PAIRING UNAVAILABLE"
263
            >
264
              <p>
265
                New pairing codes are disabled in this environment. Existing computers remain
266
                visible and can still be revoked.
267
              </p>
268
            </.alert>
269
270
            <div class="computers-notices" aria-live="polite">
271
              <.alert
272
                :if={@pairing_error}
273
                id="pairing-error"
274
                variant={:danger}
275
                appearance={:notice}
276
                label="ATTENTION"
277
              >
278
                <p>{@pairing_error}</p>
279
              </.alert>
280
281
              <.alert
282
                :if={@operation_success}
283
                id={@operation_success.id}
284
                variant={:success}
285
                appearance={:notice}
286
                label={@operation_success.label}
287
              >
288
                <p>{@operation_success.message}</p>
289
              </.alert>
290
            </div>
291
292
            <section class="computers-list-section" aria-labelledby="computers-list-heading">
293
              <header class="computers-list-heading">
294
                <div>
295
                  <h2 id="computers-list-heading">Paired computers</h2>
296
                  <p>Credential access and live presence are shown separately.</p>
297
                </div>
298
                <.badge variant={:dim}>{@machine_count} total</.badge>
299
              </header>
300
301
              <div id="computers-list" class="computers-list" phx-update="stream">
302
                <.empty
303
                  id="computers-empty"
304
                  class={["hidden", "only:block"]}
305
                  title="No computers paired"
306
                >
307
                  Run the controller on a computer you trust, then approve its one-time code here.
308
                </.empty>
309
310
                <.card
311
                  :for={{dom_id, machine} <- @streams.machines}
312
                  id={dom_id}
313
                  class="computer-card"
314
                  state={machine.status}
315
                  data-machine-id={machine.id}
316
                  data-presence={if(online?(machine, @presence), do: "online", else: "offline")}
317
                >
318
                  <div class="computer-card__top">
319
                    <div class="computer-card__identity">
320
                      <span class="computer-card__glyph"><.icon name="desktop" /></span>
321
                      <div>
322
                        <h3 title={machine.name}>{machine.name}</h3>
323
                        <span class="computer-card__presence">
324
                          <.status_indicator
325
                            state={
326
                              if(online?(machine, @presence), do: "connected", else: "unavailable")
327
                            }
328
                            label={if(online?(machine, @presence), do: "Online", else: "Offline")}
329
                            decorative
330
                          />
331
                          {if(online?(machine, @presence), do: "Online", else: "Offline")}
332
                        </span>
333
                      </div>
334
                    </div>
335
336
                    <div class="computer-card__badges">
337
                      <.badge variant={credential_variant(machine.status)}>
338
                        {String.upcase(machine.status)} ACCESS
339
                      </.badge>
340
                      <.badge variant={:dim}>{policy_label(machine.tier)}</.badge>
341
                    </div>
342
                  </div>
343
344
                  <dl class="computer-card__facts">
345
                    <div>
346
                      <dt>Platform</dt>
347
                      <dd>{machine.platform || "Not reported"}</dd>
348
                    </div>
349
                    <div>
350
                      <dt>Controller</dt>
351
                      <dd>{machine.agent_version || "Not reported"}</dd>
352
                    </div>
353
                    <div id={"computer-agent-#{machine.id}"}>
354
                      <dt>Codex</dt>
355
                      <dd>{codex_runtime(machine.last_probe)}</dd>
356
                    </div>
357
                    <div>
358
                      <dt>Scope</dt>
359
                      <dd>{root_label(machine.roots)}</dd>
360
                    </div>
361
                    <div>
362
                      <dt>Last seen</dt>
363
                      <dd>
364
                        <time
365
                          :if={machine.last_seen_at}
366
                          datetime={DateTime.to_iso8601(machine.last_seen_at)}
367
                        >
368
                          {Calendar.strftime(machine.last_seen_at, "%Y-%m-%d %H:%M UTC")}
369
                        </time>
370
                        <span :if={!machine.last_seen_at}>Never connected</span>
371
                      </dd>
372
                    </div>
373
                  </dl>
374
375
                  <footer :if={machine.status == "active"} class="computer-card__actions">
376
                    <p>Revoking closes its connection and can stop work currently running there.</p>
377
                    <.text_button
378
                      id={"revoke-#{machine.id}"}
379
                      tone={:danger}
380
                      phx-click="revoke_machine"
381
                      phx-value-id={machine.id}
382
                      phx-disable-with="Revoking…"
383
                      data-confirm={"Revoke access for #{machine.name}? Running work on this computer may stop."}
384
                    >
385
                      Revoke access
386
                    </.text_button>
387
                  </footer>
388
                </.card>
389
              </div>
390
            </section>
391
          </div>
392
        </section>
393
      </main>
394
    </Layouts.app>
395
    """
396
  end
397
398
  defp codex_runtime(%{"acp_agents" => agents}) when is_list(agents) do
399
    case Enum.find(agents, &(is_map(&1) and &1["id"] == "codex")) do
400
      %{} = agent ->
401
        [agent["model"], agent["reasoning_effort"], agent["mode"]]
402
        |> Enum.filter(&(is_binary(&1) and &1 != ""))
403
        |> case do
404
          [] -> "Adapter defaults"
405
          values -> Enum.join(values, " · ")
406
        end
407
408
      nil ->
409
        "Not available"
410
    end
411
  end
412
413
  defp codex_runtime(_probe), do: "Not reported"
414
end
lib/openagents_web/live/leaderboard_live.ex added +108

@@ -0,0 +1,108 @@

1
defmodule OpenAgentsWeb.LeaderboardLive do
2
  @moduledoc """
3
  The public leaderboard.
4
5
  A read-only projection of `OpenAgents.Leaderboard`. It mounts without a session,
6
  holds no conversation, and offers no action that can invoke Sarah, which is
7
  what lets a second public surface sit alongside the authentication boundary
8
  (`INVARIANTS.md` UI-001, LEADERBOARD-001).
9
10
  Rows arrive already computed and already bounded. The surface deliberately has
11
  no query of its own: whatever `OpenAgents.Leaderboard.Entry` does not carry cannot
12
  be rendered here by accident.
13
  """
14
15
  use OpenAgentsWeb, :sarah_live_view
16
17
  alias OpenAgents.Leaderboard
18
  alias OpenAgents.Leaderboard.Entry
19
20
  @impl true
21
  def mount(_params, _session, socket) do
22
    if connected?(socket), do: :ok = Leaderboard.subscribe()
23
24
    {:ok,
25
     socket
26
     |> assign(:page_title, "Leaderboard · Sarah")
27
     |> assign(:entries, Leaderboard.entries())}
28
  end
29
30
  @impl true
31
  def handle_info({:leaderboard_updated, entries}, socket) do
32
    {:noreply, assign(socket, :entries, entries)}
33
  end
34
35
  def handle_info(_message, socket), do: {:noreply, socket}
36
37
  @impl true
38
  def render(assigns) do
39
    ~H"""
40
    <Layouts.app flash={@flash}>
41
      <main id="leaderboard-page" class="app-shell leaderboard-shell">
42
        <Layouts.command_bar aria_label="Sarah leaderboard" current_user={@current_user}>
43
          <:lockup>
44
            <.button
45
              :if={@current_user}
46
              id="return-to-conversation"
47
              variant={:chip}
48
              size={:xs}
49
              phx-click={JS.navigate(~p"/chat")}
50
            >
51
              <.icon name="arrow-left" /> RETURN TO CONVERSATION
52
            </.button>
53
          </:lockup>
54
        </Layouts.command_bar>
55
56
        <section class="leaderboard" aria-label="Token leaderboard">
57
          <header class="leaderboard-heading">
58
            <h1>Leaderboard</h1>
59
            <p>Accounts ranked by tokens used with Sarah, across typed and spoken turns.</p>
60
          </header>
61
62
          <.empty :if={@entries == []} id="leaderboard-empty" title="No tokens yet">
63
            The board fills in as accounts talk with OpenAgents. It updates live.
64
          </.empty>
65
66
          <ol :if={@entries != []} id="leaderboard-rows" class="leaderboard-rows">
67
            <li :for={entry <- @entries} id={"leaderboard-rank-#{entry.rank}"}>
68
              <.card>
69
                <div class="leaderboard-row">
70
                  <span class="leaderboard-rank" aria-label={"Rank #{entry.rank}"}>
71
                    {entry.rank}
72
                  </span>
73
74
                  <.avatar
75
                    src={entry.github_avatar_url}
76
                    alt=""
77
                    size={:sm}
78
                    fallback={String.first(entry.github_login)}
79
                  />
80
81
                  <span class="leaderboard-identity">
82
                    <strong :if={Entry.display_name(entry)}>{Entry.display_name(entry)}</strong>
83
                    <span>@{entry.github_login}</span>
84
                  </span>
85
86
                  <.badge variant={:info} class="leaderboard-total">
87
                    {format_tokens(entry.total_tokens)} tokens
88
                  </.badge>
89
                </div>
90
              </.card>
91
            </li>
92
          </ol>
93
        </section>
94
      </main>
95
    </Layouts.app>
96
    """
97
  end
98
99
  # Long counts are the point of the board, so they get thousands separators
100
  # rather than an abbreviation that hides the difference between two accounts.
101
  defp format_tokens(total) when is_integer(total) do
102
    total
103
    |> Integer.to_string()
104
    |> String.reverse()
105
    |> String.replace(~r/(\d{3})(?=\d)/, "\\1,")
106
    |> String.reverse()
107
  end
108
end
lib/openagents_web/live/network_status_live.ex added +463

@@ -0,0 +1,463 @@

1
defmodule OpenAgentsWeb.NetworkStatusLive do
2
  @moduledoc """
3
  The public network status page (#125): anyone can watch the state of the
4
  fleet — cluster membership and quorum, per-node versions and hot-load
5
  revisions, and rollouts sweeping node to node — live, without an account.
6
7
  Same public posture as `LeaderboardLive` (LEADERBOARD-001): a read-only,
8
  bounded, content-free projection (STATUS-001) that cannot mount or invoke
9
  OpenAgents. Renders only `OpenAgentsWeb.UI` primitives (UI-003). Updates arrive over
10
  PubSub plus a slow tick; the page never requires quorum, the database, or a
11
  full fleet to render — it is most useful precisely when something is down.
12
  """
13
14
  use OpenAgentsWeb, :sarah_live_view
15
16
  alias OpenAgents.NetworkStatus
17
18
  @tick_ms 5_000
19
  @events_kept 20
20
21
  @impl true
22
  def mount(_params, _session, socket) do
23
    if connected?(socket) do
24
      :ok = NetworkStatus.subscribe()
25
26
      Enum.each(["forge:pushes", "forge:target", "forge:builds", "forge:deploys"], fn topic ->
27
        Phoenix.PubSub.subscribe(OpenAgents.PubSub, topic)
28
      end)
29
30
      if OpenAgents.Cluster.distributed?(), do: :net_kernel.monitor_nodes(true)
31
      Process.send_after(self(), :tick, @tick_ms)
32
    end
33
34
    {:ok,
35
     socket
36
     |> assign(:page_title, "Network status · Sarah")
37
     |> assign(:events, [])
38
     |> assign_projection(NetworkStatus.projection())}
39
  end
40
41
  @impl true
42
  def handle_info({:network_status, projection}, socket) do
43
    {:noreply, socket |> track_transitions(projection) |> assign_projection(projection)}
44
  end
45
46
  def handle_info(:tick, socket) do
47
    Process.send_after(self(), :tick, @tick_ms)
48
    projection = NetworkStatus.projection(refresh: true)
49
    {:noreply, socket |> track_transitions(projection) |> assign_projection(projection)}
50
  end
51
52
  def handle_info({:nodeup, _node}, socket) do
53
    {:noreply, socket |> push_event_line("a node joined the cluster") |> refresh()}
54
  end
55
56
  def handle_info({:nodedown, _node}, socket) do
57
    {:noreply, socket |> push_event_line("a node left the cluster") |> refresh()}
58
  end
59
60
  # Forge deploy-lane events (#126): each transition becomes a content-free
61
  # event line and refreshes the projection, so a promote sweeps across the
62
  # pipeline card and the per-node revisions in real time.
63
  def handle_info({:forge_push, %{wal_seq: seq}}, socket) do
64
    {:noreply, socket |> push_event_line("push received (seq #{seq})") |> refresh()}
65
  end
66
67
  def handle_info({:forge_target, %{sha: sha}}, socket) do
68
    {:noreply,
69
     socket |> push_event_line("#{short_sha(sha)} promoted as fleet target") |> refresh()}
70
  end
71
72
  def handle_info({:forge_target_status, %{sha: sha, status: status}}, socket) do
73
    {:noreply, socket |> push_event_line("#{short_sha(sha)} → #{status}") |> refresh()}
74
  end
75
76
  def handle_info({:forge_build_ready, %{sha: sha, modules: modules}}, socket) do
77
    line = "#{short_sha(sha)} built (#{length(modules)} module#{plural(modules)})"
78
    {:noreply, socket |> push_event_line(line) |> refresh()}
79
  end
80
81
  def handle_info({:forge_deploy, %{sha: sha, result: result}}, socket) do
82
    {:noreply, socket |> push_event_line("hot deploy #{short_sha(sha)}: #{result}") |> refresh()}
83
  end
84
85
  def handle_info(_message, socket), do: {:noreply, socket}
86
87
  defp refresh(socket), do: assign_projection(socket, NetworkStatus.projection(refresh: true))
88
89
  defp assign_projection(socket, projection) do
90
    # A briefly-cached projection built by a pre-#126 module (mid hot-load)
91
    # may lack the forge section — normalize rather than crash the page.
92
    projection =
93
      Map.put_new(projection, "forge", %{
94
        "target" => nil,
95
        "recent_targets" => [],
96
        "recent_deploys" => [],
97
        "loop" => %{"last_ms" => nil, "median_ms" => nil}
98
      })
99
100
    socket
101
    |> assign(:projection, projection)
102
    |> assign(:overall, overall(projection))
103
  end
104
105
  # Version divergence across reachable nodes IS the rollout visualization:
106
  # when a roll or hot deploy is sweeping the fleet, nodes disagree — surface
107
  # that as an explicit "rolling" state with progress.
108
  defp rollout(projection) do
109
    reachable = Enum.filter(projection["nodes"], & &1["reachable"])
110
111
    versions =
112
      reachable
113
      |> Enum.map(&{&1["release"], &1["revision"]})
114
      |> Enum.reject(&(&1 == {nil, nil}))
115
      |> Enum.uniq()
116
117
    case versions do
118
      [_one] ->
119
        :steady
120
121
      [] ->
122
        :steady
123
124
      _several ->
125
        newest = reachable |> Enum.map(& &1["revision"]) |> Enum.reject(&is_nil/1) |> List.last()
126
        on_newest = Enum.count(reachable, &(&1["revision"] == newest))
127
        {:rolling, on_newest, length(reachable)}
128
    end
129
  end
130
131
  defp overall(projection) do
132
    cluster = projection["cluster"]
133
    unreachable = Enum.count(projection["nodes"], &(!&1["reachable"]))
134
135
    cond do
136
      not cluster["quorum"] and cluster["beam"] > 1 -> {"degraded", "QUORUM AT RISK"}
137
      unreachable > 0 -> {"recovering", "NODE RECOVERING"}
138
      match?({:rolling, _, _}, rollout(projection)) -> {"rolling", "ROLLOUT IN PROGRESS"}
139
      projection["status"] == "ok" -> {"ok", "ALL SYSTEMS LIVE"}
140
      true -> {"degraded", "DEGRADED"}
141
    end
142
  end
143
144
  # A bounded, content-free event feed: only transitions this page itself
145
  # observed (no stored history), newest first.
146
  defp track_transitions(socket, projection) do
147
    previous = socket.assigns[:projection]
148
149
    cond do
150
      is_nil(previous) ->
151
        socket
152
153
      previous["cluster"] != projection["cluster"] ->
154
        %{"beam" => beam, "raft" => raft, "quorum" => quorum} = projection["cluster"]
155
        push_event_line(socket, "cluster now beam=#{beam} raft=#{raft} quorum=#{quorum}")
156
157
      revisions(previous) != revisions(projection) ->
158
        push_event_line(socket, "a node changed version (deploy in progress)")
159
160
      true ->
161
        socket
162
    end
163
  end
164
165
  defp revisions(projection),
166
    do: Enum.map(projection["nodes"], &{&1["release"], &1["revision"], &1["reachable"]})
167
168
  defp push_event_line(socket, line) do
169
    stamp = DateTime.utc_now() |> Calendar.strftime("%H:%M:%S UTC")
170
    events = Enum.take([{stamp, line} | socket.assigns.events], @events_kept)
171
    assign(socket, :events, events)
172
  end
173
174
  defp uptime_text(nil), do: "—"
175
176
  defp uptime_text(seconds) when is_integer(seconds) do
177
    cond do
178
      seconds >= 86_400 -> "#{div(seconds, 86_400)}d #{div(rem(seconds, 86_400), 3_600)}h"
179
      seconds >= 3_600 -> "#{div(seconds, 3_600)}h #{div(rem(seconds, 3_600), 60)}m"
180
      true -> "#{div(seconds, 60)}m"
181
    end
182
  end
183
184
  defp node_state(%{"reachable" => false}), do: "offline"
185
  defp node_state(_node), do: "connected"
186
187
  # ── forge pipeline (#126) ────────────────────────────────────────────────
188
189
  @pipeline_steps ~w(promoted building built deploying live)
190
191
  # Where the target sits in the status machine: {:progress, index} while
192
  # advancing, {:halted, status} on a terminal non-live state (failed,
193
  # reverted, needs_rolling_replace) — a revert is a story worth showing,
194
  # not hiding.
195
  defp pipeline(status) do
196
    case Enum.find_index(@pipeline_steps, &(&1 == status)) do
197
      nil -> {:halted, status}
198
      index -> {:progress, index}
199
    end
200
  end
201
202
  defp pipeline_steps, do: @pipeline_steps
203
204
  defp step_class(step_index, {:progress, at}) do
205
    cond do
206
      step_index < at ->
207
        "status-pipeline__step status-pipeline__step--done"
208
209
      step_index == at and at == length(@pipeline_steps) - 1 ->
210
        "status-pipeline__step status-pipeline__step--done"
211
212
      step_index == at ->
213
        "status-pipeline__step status-pipeline__step--active"
214
215
      true ->
216
        "status-pipeline__step"
217
    end
218
  end
219
220
  defp step_class(_step_index, {:halted, _status}), do: "status-pipeline__step"
221
222
  defp short_sha(sha) when is_binary(sha), do: String.slice(sha, 0, 12)
223
  defp short_sha(_sha), do: "—"
224
225
  defp plural([_one]), do: ""
226
  defp plural(_modules), do: "s"
227
228
  defp ms_text(ms) when is_integer(ms), do: "#{Float.round(ms / 1000, 1)}s"
229
  defp ms_text(_ms), do: "—"
230
231
  defp mirror_text(%{"state" => "current"}), do: "current"
232
233
  defp mirror_text(%{"state" => "lagging", "lagging_minutes" => minutes}),
234
    do: "lagging #{minutes}m"
235
236
  defp mirror_text(_state), do: "off"
237
238
  defp deploy_result_variant("live"), do: :success
239
  defp deploy_result_variant("reverted"), do: :warning
240
  defp deploy_result_variant("needs_rolling_replace"), do: :warning
241
  defp deploy_result_variant(_result), do: :danger
242
243
  defp overall_badge_variant("ok"), do: :success
244
  defp overall_badge_variant("rolling"), do: :info
245
  defp overall_badge_variant("recovering"), do: :warning
246
  defp overall_badge_variant(_state), do: :error
247
248
  @impl true
249
  def render(assigns) do
250
    ~H"""
251
    <Layouts.app flash={@flash}>
252
      <main id="network-status-page" class="app-shell status-shell">
253
        <Layouts.command_bar aria_label="Sarah network status" current_user={@current_user}>
254
          <:lockup>
255
            <.button
256
              :if={@current_user}
257
              id="return-to-conversation"
258
              variant={:chip}
259
              size={:xs}
260
              phx-click={JS.navigate(~p"/chat")}
261
            >
262
              <.icon name="arrow-left" /> RETURN TO CONVERSATION
263
            </.button>
264
          </:lockup>
265
        </Layouts.command_bar>
266
267
        <section class="status" aria-label="Network status">
268
          <header class="status-heading">
269
            <div>
270
              <h1>Network status</h1>
271
              <p>
272
                The Sarah fleet — a quorum cluster that survives node loss and
273
                upgrades in place. Owned sessions dropped per node loss or
274
                upgrade: 0; the release gate refuses anything that breaks that.
275
              </p>
276
            </div>
277
            <.badge id="status-overall" variant={overall_badge_variant(elem(@overall, 0))}>
278
              <.status_indicator state={elem(@overall, 0)} label={elem(@overall, 1)} decorative />
279
              {elem(@overall, 1)}
280
            </.badge>
281
          </header>
282
283
          <.card id="status-cluster">
284
            <div class="status-metrics">
285
              <div class="status-metric">
286
                <span class="status-metric__value">{@projection["cluster"]["beam"]}</span>
287
                <span class="status-metric__label">BEAM nodes</span>
288
              </div>
289
              <div class="status-metric">
290
                <span class="status-metric__value">{@projection["cluster"]["raft"]}</span>
291
                <span class="status-metric__label">Raft members</span>
292
              </div>
293
              <div class="status-metric">
294
                <span class="status-metric__value">
295
                  {if @projection["cluster"]["quorum"], do: "held", else: "lost"}
296
                </span>
297
                <span class="status-metric__label">quorum</span>
298
              </div>
299
              <div class="status-metric">
300
                <span class="status-metric__value">
301
                  {@projection["counts"]["machines_connected"] || "—"}
302
                </span>
303
                <span class="status-metric__label">computers connected</span>
304
              </div>
305
              <div class="status-metric">
306
                <span class="status-metric__value">{@projection["counts"]["active_jobs"] || "—"}</span>
307
                <span class="status-metric__label">active jobs</span>
308
              </div>
309
            </div>
310
          </.card>
311
312
          <.alert
313
            :if={match?({:rolling, _, _}, rollout(@projection))}
314
            id="status-rollout"
315
            variant={:info}
316
            title="Rollout in progress"
317
          >
318
            <% {:rolling, done, total} = rollout(@projection) %> Nodes are moving to a new version one at a time — {done}/{total} on the
319
            newest build. The cluster never drops below quorum during a roll.
320
          </.alert>
321
322
          <div id="status-nodes" class="status-nodes">
323
            <.card
324
              :for={{node, index} <- Enum.with_index(@projection["nodes"])}
325
              id={"status-node-#{index}"}
326
            >
327
              <div class="status-node">
328
                <div class="status-node__head">
329
                  <.status_indicator
330
                    state={node_state(node)}
331
                    label={if node["reachable"], do: "reachable", else: "unreachable"}
332
                  />
333
                  <strong>{node["label"]}</strong>
334
                </div>
335
                <dl :if={node["reachable"]} class="status-node__facts">
336
                  <div>
337
                    <dt>release</dt><dd>{node["release"] || "—"}</dd>
338
                  </div>
339
                  <div>
340
                    <dt>revision</dt><dd>{node["revision"] || "—"}</dd>
341
                  </div>
342
                  <div :if={node["hot_loaded_at"]}>
343
                    <dt>hot-loaded</dt><dd>{node["hot_loaded_at"]}</dd>
344
                  </div>
345
                  <div>
346
                    <dt>uptime</dt><dd>{uptime_text(node["uptime_seconds"])}</dd>
347
                  </div>
348
                  <div>
349
                    <dt>sees</dt><dd>beam={node["beam_seen"]} raft={node["raft_seen"]}</dd>
350
                  </div>
351
                </dl>
352
                <p :if={!node["reachable"]} class="status-node__facts">
353
                  Not answering — rebooting or being replaced. The survivors hold
354
                  quorum and its work relocates to them.
355
                </p>
356
              </div>
357
            </.card>
358
          </div>
359
360
          <.card id="status-forge">
361
            <h2>Rapid deploys</h2>
362
            <p class="status-forge__intro">
363
              Code moves through Sarah's own forge: a push is promoted by an
364
              operator, built into just the changed modules, and hot-loaded
365
              across the fleet without a restart — watch it sweep the nodes
366
              above.
367
            </p>
368
369
            <.empty
370
              :if={is_nil(@projection["forge"]["target"])}
371
              id="status-forge-empty"
372
              title="No deploys yet"
373
            >
374
              The next promoted commit will appear here as it rolls out.
375
            </.empty>
376
377
            <div :if={target = @projection["forge"]["target"]} class="status-forge__target">
378
              <% position = pipeline(target["status"]) %>
379
              <ol class="status-pipeline" aria-label="Deploy pipeline">
380
                <li
381
                  :for={{step, index} <- Enum.with_index(pipeline_steps())}
382
                  class={step_class(index, position)}
383
                >
384
                  {step}
385
                </li>
386
              </ol>
387
388
              <div class="status-forge__facts">
389
                <span><code>{target["sha"]}</code></span>
390
                <.badge
391
                  :if={match?({:halted, _}, position)}
392
                  id="status-forge-halted"
393
                  variant={deploy_result_variant(target["status"])}
394
                >
395
                  {target["status"]}
396
                </.badge>
397
                <span>promoted by {target["promoted_by"] || "—"}</span>
398
                <span :if={target["modules"] > 0}>
399
                  {target["modules"]} module{if target["modules"] == 1, do: "", else: "s"}
400
                </span>
401
              </div>
402
403
              <div class="status-metrics status-forge__loop">
404
                <div class="status-metric">
405
                  <span class="status-metric__value">
406
                    {ms_text(@projection["forge"]["loop"]["last_ms"])}
407
                  </span>
408
                  <span class="status-metric__label">last push→live</span>
409
                </div>
410
                <div class="status-metric">
411
                  <span class="status-metric__value">
412
                    {ms_text(@projection["forge"]["loop"]["median_ms"])}
413
                  </span>
414
                  <span class="status-metric__label">median push→live</span>
415
                </div>
416
              </div>
417
            </div>
418
419
            <p class="status-forge__intro" id="status-forge-mirror">
420
              mirror: {mirror_text(@projection["forge"]["mirror"])}
421
            </p>
422
423
            <div :if={@projection["forge"]["recent_deploys"] != []} class="status-forge__history">
424
              <h3>Recent deploys</h3>
425
              <ul class="status-events">
426
                <li :for={deploy <- @projection["forge"]["recent_deploys"]}>
427
                  <span class="status-events__stamp"><code>{deploy["sha"]}</code></span>
428
                  <.badge variant={deploy_result_variant(deploy["result"])}>
429
                    {deploy["result"]}
430
                  </.badge>
431
                  {deploy["modules"]} module{if deploy["modules"] == 1, do: "", else: "s"} · push→live {ms_text(
432
                    deploy["push_to_live_ms"]
433
                  )}
434
                </li>
435
              </ul>
436
            </div>
437
          </.card>
438
439
          <.card id="status-events">
440
            <h2>Live events</h2>
441
            <.empty :if={@events == []} id="status-no-events" title="Quiet">
442
              No transitions observed since you opened this page.
443
            </.empty>
444
            <ul :if={@events != []} class="status-events">
445
              <li :for={{stamp, line} <- @events}>
446
                <span class="status-events__stamp">{stamp}</span> {line}
447
              </li>
448
            </ul>
449
          </.card>
450
451
          <footer class="status-footer">
452
            <p>
453
              Machine-readable: <code>GET /api/status</code>
454
              (schema <code>sarah.network_status.v1</code>) ·
455
              probe: <code>GET /healthz</code>. This page updates live.
456
            </p>
457
          </footer>
458
        </section>
459
      </main>
460
    </Layouts.app>
461
    """
462
  end
463
end
lib/openagents_web/live/voice_spike_live.ex added +112

@@ -0,0 +1,112 @@

1
defmodule OpenAgentsWeb.VoiceSpikeLive do
2
  @moduledoc false
3
4
  use OpenAgentsWeb, :sarah_live_view
5
6
  @impl true
7
  def mount(_params, _session, socket) do
8
    {:ok, assign(socket, :page_title, "Sarah Voice Transport Spike")}
9
  end
10
11
  @impl true
12
  def render(assigns) do
13
    ~H"""
14
    <Layouts.app flash={@flash}>
15
      <main id="voice-spike" class="app-shell">
16
        <header class="command-bar">
17
          <div class="brand-lockup"><span class="brand-name">SARAH / VOICE SPIKE</span></div>
18
        </header>
19
        <section
20
          id="voice-spike-controller"
21
          phx-hook=".VoiceSpike"
22
          phx-update="ignore"
23
          class="transcript"
24
        >
25
          <p id="voice-spike-status" role="status" aria-live="polite">IDLE</p>
26
          <button id="voice-spike-start" type="button" class="send-action">START SPIKE</button>
27
          <button id="voice-spike-end" type="button" class="text-action" disabled>END</button>
28
          <pre id="voice-spike-events" aria-label="Provider event summary"></pre>
29
        </section>
30
      </main>
31
32
      <script :type={Phoenix.LiveView.ColocatedHook} name=".VoiceSpike">
33
        export default {
34
          mounted() {
35
            this.status = this.el.querySelector("#voice-spike-status")
36
            this.events = this.el.querySelector("#voice-spike-events")
37
            this.start = this.el.querySelector("#voice-spike-start")
38
            this.end = this.el.querySelector("#voice-spike-end")
39
            this.start.addEventListener("click", () => this.connect())
40
            this.end.addEventListener("click", () => this.endCall())
41
          },
42
          destroyed() { this.disconnect("ENDED") },
43
          async connect() {
44
            if (this.peer) return
45
            this.start.disabled = true
46
            this.setStatus("REQUESTING MICROPHONE")
47
            try {
48
              this.media = await navigator.mediaDevices.getUserMedia({audio: true})
49
              this.peer = new RTCPeerConnection()
50
              this.audio = document.createElement("audio")
51
              this.audio.autoplay = true
52
              this.peer.ontrack = event => { this.audio.srcObject = event.streams[0] }
53
              this.media.getTracks().forEach(track => this.peer.addTrack(track, this.media))
54
              this.channel = this.peer.createDataChannel("oai-events")
55
              this.channel.addEventListener("message", event => this.recordEvent(event.data))
56
              this.channel.addEventListener("open", () => this.setStatus("CONNECTED"))
57
              this.setStatus("CREATING OFFER")
58
              const offer = await this.peer.createOffer()
59
              await this.peer.setLocalDescription(offer)
60
              const csrf = document.querySelector("meta[name='csrf-token']").content
61
              const response = await fetch("/voice/calls", {
62
                method: "POST",
63
                headers: {"content-type": "application/sdp", "x-csrf-token": csrf},
64
                body: this.peer.localDescription.sdp
65
              })
66
              if (!response.ok) throw new Error(`call admission ${response.status}`)
67
              await this.peer.setRemoteDescription({type: "answer", sdp: await response.text()})
68
              this.end.disabled = false
69
            } catch (error) {
70
              this.events.textContent = error instanceof Error ? error.message : "connection failed"
71
              this.disconnect("FAILED")
72
            }
73
          },
74
          recordEvent(raw) {
75
            try {
76
              const event = JSON.parse(raw)
77
              this.events.textContent = `${event.type || "unknown"}\n${this.events.textContent}`.slice(0, 2000)
78
            } catch (_error) {
79
              this.events.textContent = "invalid provider event"
80
            }
81
          },
82
          async endCall() {
83
            const csrf = document.querySelector("meta[name='csrf-token']").content
84
            try {
85
              await fetch("/voice/calls", {
86
                method: "DELETE",
87
                headers: {"x-csrf-token": csrf}
88
              })
89
            } finally {
90
              this.disconnect("ENDED")
91
            }
92
          },
93
          disconnect(status) {
94
            if (this.channel) this.channel.close()
95
            if (this.peer) this.peer.close()
96
            if (this.media) this.media.getTracks().forEach(track => track.stop())
97
            if (this.audio) this.audio.srcObject = null
98
            this.channel = null
99
            this.peer = null
100
            this.media = null
101
            this.audio = null
102
            if (this.status) this.setStatus(status)
103
            if (this.start) this.start.disabled = false
104
            if (this.end) this.end.disabled = true
105
          },
106
          setStatus(status) { this.status.textContent = status }
107
        }
108
      </script>
109
    </Layouts.app>
110
    """
111
  end
112
end
lib/openagents_web/public_not_found_error.ex added +10

@@ -0,0 +1,10 @@

1
defmodule OpenAgentsWeb.PublicNotFoundError do
2
  @moduledoc """
3
  Raised by the public transparency surfaces when a repo, ref, path, or sha
4
  is unknown — or when a repo's disclosure level does not admit the surface
5
  at all (TRANSPARENCY-001). Renders as a plain 404: an unconfigured repo
6
  and a nonexistent one are deliberately indistinguishable from outside.
7
  """
8
9
  defexception message: "not found", plug_status: 404
10
end
lib/openagents_web/router.ex modified +9 -2

@@ -27,6 +27,12 @@ defmodule OpenAgentsWeb.Router do

27 27
    live_session :public,
28 28
      on_mount: [{OpenAgentsWeb.UserAuth, :mount_current_user}] do
29 29
      live "/", HomeLive, :index
30
      live "/status", NetworkStatusLive, :index
31
      live "/changelog", ChangelogLive, :index
32
      live "/leaderboard", LeaderboardLive, :index
33
      live "/code/:owner/:repo", CodeRepoLive, :index
34
      live "/code/:owner/:repo/blob/*path", CodeBlobLive, :index
35
      live "/code/:owner/:repo/commit/:sha", CodeCommitLive, :index
30 36
    end
31 37
32 38
    # `/components/icons` must stay ahead of `/components/:slug` so the literal

@@ -49,7 +55,6 @@ defmodule OpenAgentsWeb.Router do

49 55
    get "/auth/github/callback", AuthController, :callback
50 56
    delete "/logout", AuthController, :logout
51 57
    get "/healthz", HealthController, :show
52
    get "/status", NetworkStatusController, :show
53 58
  end
54 59
55 60
  scope "/", OpenAgentsWeb do

@@ -58,6 +63,9 @@ defmodule OpenAgentsWeb.Router do

58 63
    live_session :authenticated,
59 64
      on_mount: [{OpenAgentsWeb.UserAuth, :ensure_authenticated}] do
60 65
      live "/chat", ChatLive, :index
66
      live "/computers", ComputersLive, :index
67
      live "/admin", AdminLive, :index
68
      live "/admin/forge", AdminForgeLive, :index
61 69
62 70
      live "/:owner/:repo/issues/new", IssueNewLive, :new
63 71
      live "/:owner/:repo/issues/:number", IssueShowLive, :show

@@ -83,7 +91,6 @@ defmodule OpenAgentsWeb.Router do

83 91
    delete "/data", DataController, :delete
84 92
    delete "/data/reset", DataController, :reset
85 93
86
    get "/computers", ComputersController, :index
87 94
    get "/machines", ComputersController, :index
88 95
    get "/api/computers", ComputersController, :index
89 96
    post "/api/computers/pairings/:id/approve", ComputersController, :approve_pairing

This page updates live while a promote is in flight · changelog