Add a docs header with breadcrumb and copy control

69a29c9a5ac2 · AtlantisPleb · · parent f19d329a69b3

Add a docs header with breadcrumb and copy control

The component library had no orientation: every page opened on a heading
with no indication of where it sat in the library or how to get back.

Adds two governed components and a header that uses them.

breadcrumb/1 -- an ancestor trail whose last item is the current page and
is therefore NOT a link. A link to the page you are already on is a dead
control that still looks live, so the current crumb renders as text with
aria-current="page". Separators are aria-hidden, so assistive technology
reads a list of places rather than a stream of glyphs.

copy_button/1 -- copies text and then says so. The confirmation is the
whole point: a copy control that changes nothing on click leaves the
reader unsure whether it worked. Flips data-copied, swaps the glyph for a
tick, and returns after a moment. Both glyphs stay in the flow so the
control does not resize when it flips and nudge everything beside it. The
state is presentational and per-visitor, so it lives on the element via a
colocated hook rather than in the LiveView.

The header spans the content column rather than the whole page: the trail
names where you are within the library, and the controls act on the page
being read, so neither belongs over the sidebar. The breadcrumb takes the
free space and truncates, so a long component name never collides with
the controls.

Also removes the palette comments' attribution to the product the ramp was
sampled from, keeping the reasoning without the name.

mix precommit green: 1414 tests, 17 JS.

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
  • modified assets/css/openagents.css
  • modified docs/2026-08-20-scv-swarm-visualization-unit-audit.md
  • modified lib/openagents_web/component_catalog.ex
  • modified lib/openagents_web/components/layouts/components.html.heex
  • modified lib/openagents_web/components/ui.ex
  • modified lib/openagents_web/live/components_live.ex

Diff

7 files changed, +340 -23

assets/css/app.css modified +18 -17

@@ -25,6 +25,7 @@

25 25
 * only source of that geometry, so nothing here is optional. This list is the
26 26
 * exact structure consumed by `OpenAgentsWeb.UI`. */
27 27
@import "../vendor/basecoat/base/base.css";
28
@import "../vendor/basecoat/components/breadcrumb.css";
28 29
@import "../vendor/basecoat/components/button.css";
29 30
@import "../vendor/basecoat/components/button-group.css";
30 31
@import "../vendor/basecoat/components/input.css";

@@ -76,24 +77,25 @@

76 77
 * surface and inverse control invert on their own when the ink flips — that is
77 78
 * the whole point of deriving them rather than listing them twice.
78 79
 *
79
 * Values are Linear's, taken from a saved copy of their docs: neutral to
80
 * faintly cool, never blue. The previous palette was tinted (chroma ~.03-.07
81
 * around hue 263) which read as blue on every surface.
80
 * The ramp is neutral to faintly cool, never blue. The previous palette was
81
 * tinted (chroma ~.03-.07 around hue 263), which read as blue on every
82
 * surface -- borders, muted text, and each of the three ink rungs.
82 83
 */
83 84
:root {
84 85
  color-scheme: dark;
85 86
86
  /* PRIMITIVES — Linear's dark ramp, verbatim.
87
     void #08090a is their --color-bg-primary, surface #0f1011 their level-1
88
     panel, raised #141516 their level-2. Raised is LIGHTER than void; light
89
     mode keeps that direction rather than flipping it. */
87
  /* PRIMITIVES — the dark ramp.
88
     Three surface rungs, each a small step up in lightness: page and sidebar on
89
     void, the working canvas on surface, elevated components on raised. Raised
90
     is LIGHTER than void; light mode keeps that direction rather than flipping
91
     it, so a card reads as nearer the reader in both themes. */
90 92
  --ink-void: #08090a; /* darkest: page, sidebar */
91 93
  --ink-surface: #0f1011; /* +1: canvas, chrome */
92 94
  --ink-raised: #141516; /* +2: elevated components */
93 95
  --text-ink: #f7f8f8; /* the one text/icon/wash ink (their --color-fg-primary) */
94 96
95
  /* The single accent. Linear's brand indigo, used only for the primary action
96
     and links; every other surface is neutral.
97
  /* The single accent, used only for the primary action and links; every other
98
     surface is neutral.
97 99
     NAME COLLISION, deliberate: Basecoat's base.css also defines --accent, but
98 100
     as shadcn does — a subtle hover background paired with --accent-foreground,
99 101
     not a brand color. Ours is defined later at equal specificity so it wins.

@@ -107,9 +109,9 @@

107 109
  --warning: #d4b144;
108 110
  --danger: #eb5757;
109 111
110
  /* DERIVATIONS — text tiers. Literals are Linear's fg ramp; the percentages
111
     beside them are the equivalent alpha step, kept so the ladder stays legible
112
     if a later theme wants to derive rather than list. */
112
  /* DERIVATIONS — text tiers. The percentages beside each literal are the
113
     equivalent alpha step, kept so the ladder stays legible if a later theme
114
     wants to derive these rather than list them. */
113 115
  --text-primary: color-mix(in oklab, var(--text-ink) 100%, transparent); /* 100% */
114 116
  --text-body: #d0d6e0; /* their fg-secondary */
115 117
  --text-muted: #8a8f98; /* their fg-tertiary */

@@ -138,7 +140,7 @@

138 140
  --control-inverse-bg: var(--text-primary);
139 141
  --control-inverse-fg: var(--ink-void);
140 142
141
  /* Borders. Linear's border ramp. */
143
  /* Borders. Two rungs: a resting hairline and a stronger edge for controls. */
142 144
  --line: #23252a; /* their --color-border-primary */
143 145
  --line-soft: color-mix(in oklab, var(--text-ink) 8%, transparent); /* 8% */
144 146
  --line-faint: color-mix(in oklab, var(--text-ink) 4%, transparent); /* 4% */

@@ -147,10 +149,9 @@

147 149
148 150
/* LIGHT.
149 151
 *
150
 * Linear publishes no light palette (their site is dark-only), so this is
151
 * derived in the same language rather than copied: the same neutral hue
152
 * discipline, the same surface-ladder direction (raised is lighter than void),
153
 * and the same ink flipped to their darkest value.
152
 * Derived from the dark ramp rather than authored separately: the same neutral
153
 * hue discipline, the same surface-ladder direction (raised is lighter than
154
 * void), and the ink flipped to the darkest rung.
154 155
 *
155 156
 * Only the primitives and the four literal exceptions are restated. The icon
156 157
 * tiers, washes, hover surface and inverse control are all derived from
assets/css/openagents.css modified +116

@@ -1833,3 +1833,119 @@

1833 1833
    }
1834 1834
  }
1835 1835
}
1836
1837
/* ── Breadcrumb ───────────────────────────────────────────────────────────── */
1838
1839
/* Basecoat supplies the list structure; this supplies the voice. The trail is
1840
 * quiet by default and only the hovered ancestor brightens -- a breadcrumb is
1841
 * orientation, not a call to action, so it should not compete with the page. */
1842
1843
@layer components {
1844
  .breadcrumb {
1845
    font-size: 0.8125rem;
1846
    line-height: 1.25rem;
1847
    color: var(--text-dim);
1848
  }
1849
1850
  .breadcrumb > ol {
1851
    gap: 6px;
1852
  }
1853
1854
  .breadcrumb li {
1855
    gap: 6px;
1856
  }
1857
1858
  .breadcrumb__separator {
1859
    color: var(--line-strong);
1860
    user-select: none;
1861
  }
1862
1863
  .breadcrumb__link {
1864
    color: var(--text-muted);
1865
    text-decoration: none;
1866
    transition: color var(--motion-fast) var(--ease);
1867
  }
1868
1869
  @media (hover: hover) {
1870
    .breadcrumb__link:hover {
1871
      color: var(--text-primary);
1872
    }
1873
  }
1874
1875
  .breadcrumb__link:focus-visible {
1876
    outline: 2px solid var(--ring);
1877
    outline-offset: 2px;
1878
  }
1879
1880
  .breadcrumb__current {
1881
    color: var(--text-body);
1882
  }
1883
}
1884
1885
/* ── Docs header ──────────────────────────────────────────────────────────── */
1886
1887
/* Breadcrumb on the left, controls pushed right by the trail's own growth
1888
 * rather than a fixed width, so a long component name never collides with the
1889
 * controls -- it truncates instead. */
1890
1891
@layer components {
1892
  .docs-column {
1893
    display: flex;
1894
    flex: 1;
1895
    flex-direction: column;
1896
    min-width: 0;
1897
    overflow: hidden;
1898
  }
1899
1900
  .docs-header {
1901
    display: flex;
1902
    flex: none;
1903
    align-items: center;
1904
    gap: 12px;
1905
    min-height: 52px;
1906
    padding-inline: 32px;
1907
    border-bottom: 1px solid var(--line);
1908
    background: var(--ink-void);
1909
  }
1910
1911
  .docs-header .breadcrumb {
1912
    min-width: 0;
1913
    margin-right: auto;
1914
  }
1915
1916
  .docs-header .breadcrumb__current {
1917
    overflow: hidden;
1918
    text-overflow: ellipsis;
1919
    white-space: nowrap;
1920
  }
1921
1922
  .docs-header__controls {
1923
    display: flex;
1924
    flex: none;
1925
    align-items: center;
1926
    gap: 8px;
1927
  }
1928
}
1929
1930
/* ── Copy button ──────────────────────────────────────────────────────────── */
1931
1932
@layer components {
1933
  .copy-button {
1934
    gap: 6px;
1935
  }
1936
1937
  /* One glyph is shown at a time; both occupy the flow so the control does not
1938
     resize when it flips, which would nudge everything beside it. */
1939
  .copy-button__done {
1940
    display: none;
1941
  }
1942
1943
  .copy-button[data-copied="true"] .copy-button__idle {
1944
    display: none;
1945
  }
1946
1947
  .copy-button[data-copied="true"] .copy-button__done {
1948
    display: inline-flex;
1949
    color: var(--success);
1950
  }
1951
}
docs/2026-08-20-scv-swarm-visualization-unit-audit.md modified +2 -2

@@ -155,8 +155,8 @@ COLOR_BLUE = '#0066ff' COLOR_LINK_BLUE = '#1d62c9'

155 155
```
156 156
157 157
**This is directly compatible with the palette this repo just adopted.**
158
`assets/css/app.css` now carries Linear's neutral ramp (`#08090a` → `#f7f8f8`)
159
with a single indigo accent. Unit's structure — a long neutral ramp plus sparse
158
`assets/css/app.css` now carries a neutral ramp (`#08090a` → `#f7f8f8`) with a
159
single indigo accent. Unit's structure — a long neutral ramp plus sparse
160 160
semantic accents, with links a shade darker than fills — maps onto our existing
161 161
tokens without introducing a third palette, which `UI-003` forbids
162 162
(`INVARIANTS.md:1520`: "not introduce a third palette").
lib/openagents_web/component_catalog.ex modified +14

@@ -58,6 +58,20 @@ defmodule OpenAgentsWeb.ComponentCatalog do

58 58
          source: "OpenAgentsWeb.UI.field/1",
59 59
          summary: "Wrapper that stacks a label, control, and validation message."
60 60
        },
61
        %{
62
          slug: "openagents-breadcrumb",
63
          title: "Breadcrumb",
64
          icon: "compass",
65
          source: "OpenAgentsWeb.UI.breadcrumb/1",
66
          summary: "Ancestor trail ending in the current page, which is not a link."
67
        },
68
        %{
69
          slug: "openagents-copy-button",
70
          title: "Copy button",
71
          icon: "copy",
72
          source: "OpenAgentsWeb.UI.copy_button/1",
73
          summary: "Copies text and confirms it, so the reader is not left guessing."
74
        },
61 75
        %{
62 76
          slug: "openagents-header",
63 77
          title: "Header",
lib/openagents_web/components/layouts/components.html.heex modified +28 -4

@@ -57,8 +57,32 @@

57 57
    </nav>
58 58
  </aside>
59 59
60
  <main class="docs-main">
61
    <.flash_group flash={@flash} />
62
    {@inner_content}
63
  </main>
60
  <div class="docs-column">
61
    <%!-- The header spans the content column, not the sidebar: the trail names
62
    where you are within the library, and the controls act on the page you are
63
    reading. Same arrangement as the docs surface it is modelled on. --%>
64
    <header class="docs-header">
65
      <UI.breadcrumb label="Component library">
66
        <:item navigate={~p"/components"}>Components</:item>
67
        <:item :if={assigns[:section_title]}>{@section_title}</:item>
68
        <:item :if={assigns[:item]}>{@item.title}</:item>
69
      </UI.breadcrumb>
70
71
      <div class="docs-header__controls">
72
        <Layouts.theme_toggle />
73
        <UI.copy_button
74
          :if={assigns[:item]}
75
          id="copy-component-source"
76
          text={@item.source}
77
          label="Copy source"
78
          copied_label="Copied"
79
        />
80
      </div>
81
    </header>
82
83
    <main class="docs-main">
84
      <.flash_group flash={@flash} />
85
      {@inner_content}
86
    </main>
87
  </div>
64 88
</div>
lib/openagents_web/components/ui.ex modified +106

@@ -776,6 +776,112 @@ defmodule OpenAgentsWeb.UI do

776 776
    """
777 777
  end
778 778
779
  @doc """
780
  A trail of ancestor links ending in the current page.
781
782
  The last item is the current page: it renders as `aria-current="page"` and is
783
  not a link, because linking a page to itself is a dead control that still
784
  looks live. Separators carry `aria-hidden` so assistive technology reads the
785
  trail as a list of places rather than a stream of glyphs.
786
  """
787
  attr :class, :any, default: nil
788
  attr :label, :string, default: "Breadcrumb"
789
  attr :rest, :global
790
791
  slot :item, required: true do
792
    attr :navigate, :string
793
    attr :patch, :string
794
    attr :href, :string
795
  end
796
797
  def breadcrumb(assigns) do
798
    assigns = assign(assigns, :last_index, length(assigns.item) - 1)
799
800
    ~H"""
801
    <nav class={["breadcrumb", @class]} aria-label={@label} {@rest}>
802
      <ol>
803
        <li :for={{item, index} <- Enum.with_index(@item)}>
804
          <span :if={index > 0} class="breadcrumb__separator" aria-hidden="true">/</span>
805
          <.link
806
            :if={index < @last_index}
807
            navigate={item[:navigate]}
808
            patch={item[:patch]}
809
            href={item[:href]}
810
            class="breadcrumb__link"
811
          >
812
            {render_slot(item)}
813
          </.link>
814
          <span :if={index == @last_index} aria-current="page" class="breadcrumb__current">
815
            {render_slot(item)}
816
          </span>
817
        </li>
818
      </ol>
819
    </nav>
820
    """
821
  end
822
823
  @doc """
824
  A control that copies text to the clipboard and reports that it did.
825
826
  The confirmation is the point. A copy button that changes nothing on click
827
  leaves the reader unsure whether it worked, so this flips `data-copied` and
828
  swaps the glyph for a tick, then returns. The state lives on the element
829
  rather than in the LiveView because it is presentational and per-visitor.
830
  """
831
  attr :id, :string, required: true
832
  attr :text, :string, required: true, doc: "the text placed on the clipboard"
833
  attr :label, :string, default: "Copy"
834
  attr :copied_label, :string, default: "Copied"
835
  attr :class, :any, default: nil
836
  attr :rest, :global
837
838
  def copy_button(assigns) do
839
    ~H"""
840
    <button
841
      id={@id}
842
      type="button"
843
      class={["btn copy-button", @class]}
844
      data-variant="secondary"
845
      data-size="sm"
846
      data-copied="false"
847
      data-copy-text={@text}
848
      data-copied-label={@copied_label}
849
      aria-label={@label}
850
      phx-hook=".CopyToClipboard"
851
      {@rest}
852
    >
853
      <.icon name="copy" class="copy-button__idle" />
854
      <.icon name="check" class="copy-button__done" />
855
      <span class="copy-button__label">{@label}</span>
856
    </button>
857
    <script :type={Phoenix.LiveView.ColocatedHook} name=".CopyToClipboard">
858
      export default {
859
        mounted() {
860
          this.el.addEventListener("click", async () => {
861
            try {
862
              await navigator.clipboard.writeText(this.el.dataset.copyText)
863
            } catch (_error) {
864
              return
865
            }
866
            const label = this.el.querySelector(".copy-button__label")
867
            const original = label && label.textContent
868
            this.el.dataset.copied = "true"
869
            if (label) label.textContent = this.el.dataset.copiedLabel
870
            clearTimeout(this.resetTimer)
871
            this.resetTimer = setTimeout(() => {
872
              this.el.dataset.copied = "false"
873
              if (label) label.textContent = original
874
            }, 1600)
875
          })
876
        },
877
        destroyed() {
878
          clearTimeout(this.resetTimer)
879
        }
880
      }
881
    </script>
882
    """
883
  end
884
779 885
  @doc """
780 886
  One glyph from the governed two-tier icon set.
781 887
lib/openagents_web/live/components_live.ex modified +56

@@ -81,6 +81,7 @@ defmodule OpenAgentsWeb.ComponentsLive do

81 81
     socket
82 82
     |> assign(:page_title, "Components")
83 83
     |> assign(:active_component, :index)
84
     |> assign(:section_title, nil)
84 85
     |> assign(:item, nil)}
85 86
  end
86 87

@@ -97,6 +98,7 @@ defmodule OpenAgentsWeb.ComponentsLive do

97 98
         socket
98 99
         |> assign(:page_title, item.title)
99 100
         |> assign(:active_component, item.slug)
101
         |> assign(:section_title, section_title_for(item.slug))
100 102
         |> assign(:item, item)}
101 103
    end
102 104
  end

@@ -690,4 +692,58 @@ defmodule OpenAgentsWeb.ComponentsLive do

690 692
    </div>
691 693
    """
692 694
  end
695
696
  defp component_demo(%{item: %{slug: "openagents-breadcrumb"}} = assigns) do
697
    ~H"""
698
    <div class="space-y-6">
699
      <div class="space-y-2">
700
        <p class="text-sm text-base-content/60">
701
          A full trail. The last item is the current page, so it is text rather than a
702
          link — a link to the page you are already on is a dead control that still
703
          looks live.
704
        </p>
705
        <UI.breadcrumb>
706
          <:item navigate={~p"/"}>OpenAgents</:item>
707
          <:item navigate={~p"/components"}>Components</:item>
708
          <:item>Breadcrumb</:item>
709
        </UI.breadcrumb>
710
      </div>
711
712
      <div class="space-y-2">
713
        <p class="text-sm text-base-content/60">Two levels, and a lone root.</p>
714
        <UI.breadcrumb>
715
          <:item navigate={~p"/components"}>Components</:item>
716
          <:item>Badge</:item>
717
        </UI.breadcrumb>
718
        <UI.breadcrumb>
719
          <:item>OpenAgents</:item>
720
        </UI.breadcrumb>
721
      </div>
722
    </div>
723
    """
724
  end
725
726
  defp component_demo(%{item: %{slug: "openagents-copy-button"}} = assigns) do
727
    ~H"""
728
    <div class="space-y-3">
729
      <p class="text-sm text-base-content/60">
730
        Click it. The glyph becomes a tick and the label changes for a moment, then
731
        returns — a copy control that changes nothing leaves you unsure it worked.
732
      </p>
733
      <UI.copy_button
734
        id="demo-copy-button"
735
        text="OpenAgentsWeb.UI.copy_button/1"
736
        label="Copy source"
737
      />
738
    </div>
739
    """
740
  end
741
742
  # The breadcrumb names the section a component lives in, so the trail matches
743
  # the sidebar the reader navigated through.
744
  defp section_title_for(slug) do
745
    Enum.find_value(ComponentCatalog.sections(), fn section ->
746
      if Enum.any?(section.items, &(&1.slug == slug)), do: section.title
747
    end)
748
  end
693 749
end

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