1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
require "pro_tacts/admin/phlex"

require "pro_tacts"
require "pro_tacts/admin/icons"
require "pro_tacts/admin/search_dialog"

module ProTacts
  module Admin
    # The one page shell every admin screen renders inside: head, the
    # vendored Gloss stylesheets (see public/vendor/gloss and
    # docs/DESIGN.md), and a one-line header naming the app in type —
    # "no brand mark" is one of the rules that document inherits from
    # Gloss — with the search's button beside the name on every
    # screen: search-first (docs/DESIGN.md) wants finding a contact
    # possible from anywhere, and the dialog it opens rides along in
    # every render. `wide` opts a screen out of the reading width a
    # single column wants (see admin.css) — the dashboard, and the
    # import walk's screens.
    #
    # Alpine loads on every screen, and it is the only script here.
    # It is for adding a row to a form without a round trip: CSS can
    # reveal a fixed set of elements but cannot make one, and any
    # version built out of `:has()` and hidden inputs trades a real
    # affordance away — a cap on how many, or a field that stays
    # behind after it was used. Alpine is the smallest thing that
    # buys back the missing verb. Where markup alone still does the
    # job it keeps doing it: the popovers open and close by the
    # Popover API, not by script.
    class Layout < Phlex::HTML
      # The account menu's popover, opened by the button that names
      # the login.
      MENU = "user-menu" #: String
      private_constant :MENU

      # @rbs @title: String
      # @rbs @login: String
      # @rbs @wide: bool
      # @rbs @searching: bool
      # @rbs @notice: String?

      # `searching` is the search page saying it is the screen: the
      # phone's link to the search would lead back to where it is.
      #: (title: String, login: String, ?wide: bool, ?searching: bool, ?notice: String?) -> void
      def initialize(title:, login:, wide: false, searching: false, notice: nil)
        @title = title
        @login = login
        @wide = wide
        @searching = searching
        @notice = notice
      end

      def view_template
        doctype
        # A configured accent marks the root, and admin.css repaints the
        # accent and the chrome for it: `rake dev` names one so a working
        # copy cannot pass for the deployment at a glance (docs/DESIGN.md).
        accent = ProTacts.config.accent
        html(lang: "en", **(accent ? {data: {accent:}} : {})) do
          head do
            meta(charset: "utf-8")
            meta(name: "viewport", content: "width=device-width, initial-scale=1")
            # The instance's name, so a tab says which server it is
            # (`rake dev` names its own).
            title { "#{ProTacts.config.instance_name} — #{@title}" }
            link(rel: "icon", href: ProTacts.config.favicon)
            # No webfont (docs/DESIGN.md), so no font link among these.
            link(rel: "stylesheet", href: "/vendor/gloss/tokens.css")
            link(rel: "stylesheet", href: "/vendor/gloss/base.css")
            link(rel: "stylesheet", href: "/vendor/gloss/components.css")
            link(rel: "stylesheet", href: "/admin.css")
            # Alpine, vendored and pinned (rake alpine:vendor) rather
            # than pulled from a CDN, for the reason the Gloss CSS is:
            # this app is reachable only over Tailscale and should not
            # need anything else reachable to render. `defer` is
            # Alpine's own requirement — it initializes on
            # DOMContentLoaded and must not run before the markup it
            # reads exists. A plugin goes before the core: it
            # registers on the alpine:init the core fires on starting,
            # and deferred scripts run in document order.
            script(defer: true, src: "/vendor/alpine/resize.min.js")
            script(defer: true, src: "/vendor/alpine/alpine.min.js")
          end
          body do
            header(class: "admin-header") do
              # The name and, beside it, the one control every screen
              # shares: what this app is and how it is asked a
              # question, on the left where reading starts.
              a(href: "/") { "pro-tacts" }
              # The search's two ways in, one shown at each width
              # (admin.css): on a wide screen a button opening the
              # dialog over the page (SearchDialog), in the header's
              # middle; on a phone a link, just the glyph, to the
              # search page (SearchPage), which has the whole screen
              # for its results. Neither is the input itself, so the
              # header is one height on every screen. On the search
              # page itself the phone's glyph is home instead, the
              # name that is the way home elsewhere being hidden there.
              button(type: "button", class: "search-button", data_size: "sm",
                     popovertarget: SearchDialog::ID) do
                render Icon.new(:search)
                span { "Search" }
              end
              if @searching
                a(href: "/", class: "icon-button phone-glyph", aria_label: "Home") { render Icon.new(:house) }
              else
                a(href: "/search", class: "icon-button phone-glyph", aria_label: "Search") { render Icon.new(:search) }
              end
              div(class: "header-end") do
                # The destinations and the account, at the right edge
                # in one box, so the search can take the middle between
                # it and the name (admin.css):
                # the contacts list is the browse surface and the groups
                # list is where a group is created — both reachable from
                # anywhere (docs/DESIGN.md, "The core idea"), search
                # still finding a group by name — with who is asking
                # named beside them. On a phone the two move into the
                # account menu with home, the name's own link (the list
                # below repeats all three for that), leaving the header
                # the search and the account.
                nav do
                  a(href: "/contacts") { "contacts" }
                  a(href: "/groups") { "groups" }
                end
                # The account the proxy vouches for (ProxyAuth), named
                # where the session reads as belonging to someone. The
                # button opens a menu rather than a page because the
                # account has no page: a login decides which cards sync
                # to whose devices (docs/plans/2026-09-12-per-user-
                # books.md) and nothing else, so what hangs off it is
                # the destinations that are not records and so
                # cannot be searched for — import, a handful of times in
                # a book's life (docs/plans/2026-09-21-import-a-vcf.md),
                # export, its counterpart, and device setup. A popover
                # by the Popover API, like
                # every floated layer here; the list sits beside its
                # button in the header because a promoted popover is
                # positioned against the viewport, not the DOM beside
                # its button, so admin.css anchors it to the wrapper by
                # name — while the Popover API still renders it above
                # everything when open.
                div(class: "user-menu") do
                  button(type: "button", data_size: "sm", popovertarget: MENU) do
                    span { @login }
                    render Icon.new(:chevron_down)
                  end
                  ul(id: MENU, popover: "auto", class: "user-menu-list") do
                    li(class: "menu-nav") { a(href: "/") { "home" } }
                    li(class: "menu-nav") { a(href: "/contacts") { "contacts" } }
                    li(class: "menu-nav") { a(href: "/groups") { "groups" } }
                    li { a(href: "/import") { "import" } }
                    li { a(href: "/contacts/export.vcf") { "export" } }
                    li { a(href: "/setup") { "device setup" } }
                  end
                end
              end
            end
            # Out of the header's row: a popover opens in the top
            # layer from wherever it sits.
            render SearchDialog.new
            main(class: "admin-main", **(@wide ? {data: {wide: true}} : {})) { yield }
            # The shell's other edge: which build is answering and
            # whether it is logging what it answers, both read off
            # the environment (see Config) rather than passed down
            # through every screen's call — these are the shell's own
            # facts, the same on all of them.
            #
            # The version is the image's tag and the release's name at
            # once, so it stands alone: the commit and the build time
            # are spelled inside it, and a second label repeating either
            # would be the same fact twice. `rake dev` stamps the jj
            # change it runs from instead; a run with neither says
            # nothing rather than guessing what it is. Centered, with
            # nothing else in the footer: the frame's other edge is
            # the build's own label, and a build with no label still
            # draws the edge.
            #
            # Debug logging says so while it is on because it dumps
            # every sync whole, contact data included (see ExchangeLog),
            # and a log left recording is the kind of thing found months
            # later. Off, the label is absent: the footer states what is
            # true, and the quiet case is the normal one. It reads as a
            # flag on the version rather than a sentence beside it —
            # "+debug" is a build with something switched on, which is
            # what it is, and the plus is its own separator.
            footer(class: "admin-footer") do
              version = ProTacts.config.version
              span(class: "type-label") { version } if version
              span(class: "type-label") { "+debug" } if ProTacts.config.debug?
            end
            # A server-rendered toast, Gloss's [role=status] contract:
            # the one line a refused write leaves behind, fixed to the
            # corner until the next navigation carries it off — no
            # dismiss control, "leave" being the design doc's own word
            # for what a toast does next.
            if @notice
              div(role: "status", data: {fixed: true}) { span { @notice } }
            end
          end
        end
      end
    end
  end
end