Files
buzz/admin-web/src/styles.css
Will Pfleger 86b9142a09 refactor(relay): NIP-98 admin auth with Operator/Moderator roles and NIP-11 discovery (#3777)
Adds authenticated, role-based moderation to the relay admin API. On
`main` the admin API is read-only and gated only by `Host`/`Origin`
matching; this branch adds NIP-98 authentication, a two-tier
Operator/Moderator principal model, mutation and staffing endpoints, and
NIP-11 auto-discovery so clients never type the admin URL by hand.

## Authentication (`BUZZ_ADMIN_AUTH`)

`BUZZ_ADMIN_AUTH` accepts `nip98` or `disabled`. Leaving it unset
defaults to `nip98` (fail-secure). Configuration fails closed: any other
value aborts startup, while a lingering `BUZZ_ADMIN_TOKEN` is ignored
with a startup warning — token (bearer) authentication is not supported.
`Host`/`Origin` matching is retained in every mode as defense-in-depth.

- **`nip98`** (default) — per-request signed NIP-98 (kind 27235) events,
resolved to an Operator or Moderator principal with per-person
attribution and individual revocability. Read-write per resolved
principal.
- **`disabled`** — no credential; relies entirely on network-layer
controls (reverse proxy, VPN, firewall) and logs a `WARN` on every boot.
Always read-only: `authorize()` resolves no principal, so mutation and
staffing routes always `403`.

## Roles

Buzz has two independent authority axes after this change.
**Relay-level** roles (new here) are deployment-global: they act across
every community on the relay, through the admin API. **Community-level**
roles (pre-existing, unchanged by this PR) are tenant-scoped: they act
inside one community, through signed Nostr moderation commands.

### Relay level (new)

| Role | Description |
|---|---|
| **Operator** | Full control of the deployment's moderation surface:
read all reports, feedback, and attachments across every community;
resolve reports with enforcement (`delete`/`kick`/`ban`/`timeout`) or
decisions (`dismiss`/`escalate`); reopen and cancel; update feedback
status; and manage the Operator/Moderator roster via the staffing
endpoints. |
| **Moderator** | Day-to-day triage: everything an Operator can do
except staffing — cannot view or change the roster. |

How a pubkey acquires a relay role (resolution order; config always
outranks DB):

1. Listed in `RELAY_OPERATOR_PUBKEYS` → **Operator** (source `config`)
2. Equals `RELAY_OWNER_PUBKEY` while `RELAY_OPERATOR_PUBKEYS` is empty →
**Operator** (source `owner_fallback`, a break-glass grant for
self-hosters that deactivates once any operator is configured)
3. Row in the `relay_operators` table → **Operator** or **Moderator**
(source `db`, managed via the staffing endpoints)
4. No match → `403`

### Community level (pre-existing, unchanged)

| Role | Description |
|---|---|
| **Owner** (community) | Full authority within their community: every
moderation action (delete, kick, ban/unban, timeout/untimeout, resolve
reports, view queue) plus member, role, and invite management. No guard
rails. |
| **Admin** (community) | Same community-wide moderation capabilities as
owner, except an admin cannot ban or time out the owner or a fellow
admin — only the owner may action an admin. Manages members and invites;
only the owner grants the admin role. |
| **Member** (community) | Standard participant; no moderation
capability. |
| **Owner / Admin** (channel) | Channel-local authority only: delete
messages and kick users within their own channel. |
| **Member / Guest / Bot** (channel) | No moderation authority. |

There is no community-level Moderator tier in v1; relay-level Moderator
is the only role by that name.

## Escalation scoping

The operator report queue is an escalation backstop, not the community's
day-to-day triage surface (per `VISION_MODERATION`, the severe class is
the platform's to review rather than the community's). Two rules enforce
that:

- **Escalated-by-default listing.** `GET /reports` with no `status`
parameter returns only `escalated` reports. An explicit
`status=<open|resolved|dismissed|escalated>` filter is always honored as
given, and full visibility across every status stays available for
platform-safety and legal review via `scope=all` (which lists reports
regardless of status). `scope` accepts only `all` and is ignored when an
explicit `status` is present.
- **Auto-escalated `illegal` reports.** Member reports whose category is
`illegal` are ingested with `status=escalated` rather than `open`, so
the severe class reaches the operator backstop without waiting for a
community admin to forward it. Every other category still lands `open`.
Auto-escalation only sets the queue status — it records no moderator
decision and stamps no resolver, so an auto-escalated report is
indistinguishable downstream from an admin-escalated one: the reopen
route returns it to `open` on the same terms, keyed only on status,
never on how the report became escalated.

## Principal resolution and NIP-98 admission

`resolve_admin_principal()` returns `AdminPrincipal { pubkey, role,
source }` per the resolution order above; `None` never falls through as
a role. Admission is ordered so the replay guard is a privilege, not a
public surface: signature/URL/method/payload-hash verification first,
roster check second, and only then is the deployment-scoped replay id
atomically consumed — a validly-signing but unrostered key never
allocates a replay slot. Redis failure fails closed.

## Report resolution, recovery, and enforcement provenance

`POST /reports/{id}/resolve` is a crash-safe enforcement state machine:
decision-only outcomes (`dismiss`/`escalate`) are a single
CAS-plus-audit transaction; enforcement
(`delete`/`kick`/`ban`/`timeout`) claims the report
(`open`→`processing`), runs the durable mutation, then finalizes — a
re-drive resumes at the step marker and converges to exactly-one
enforcement, fenced by a lease and an outbox claim token.

Person-directed enforcement on an `event`-kind report derives its target
from the stored event's author (server-owned truth, never the reporter's
`p` tag) via a single `derive_enforcement_target` shared by the HTTP
driver and the recovery worker. If the reported event was purged before
its author could be read, person-directed actions are rejected pre-claim
and the report stays `open`; `delete` needs only the event id and is
exempt.

`GET /reports/{id}` and the resolve response carry an `activeAction`
field surfacing the enforcement that actually executed — a report
dismissed after a reopen still reports the ban that ran. `POST
/reports/{id}/reopen` returns a terminal report to `open` (idempotent on
`requestId`). `POST /reports/{id}/cancel` is the sole recovery path for
a pre-mutation `failed` action, attributed via
`relay_admin_actions.cancelled_by`.

## Feedback

`GET /feedback` and `/feedback/{id}` survive a tenant purge: provenance
columns are severed to `NULL` rather than cascade-deleted, and the
attachment path fails closed to `404` on a severed row. `PATCH
/feedback/{id}` updates lifecycle `status`
(`new`/`reviewed`/`archived`).

## Staffing and probe

`GET/PUT/DELETE /operators/{pubkey}` are Operator-only; mutating a
config-backed pubkey returns `409 Conflict`. `GET /operators` returns
the union of config and DB principals with per-entry `source`. `GET
/probe` reports auth mode, role, source, `canAct`, and `canStaff` for
the desktop console.

## NIP-11 auto-discovery

The NIP-11 relay-information document gains an optional `admin_api`
field carrying the canonical admin origin (`scheme://host[:port]`, no
path), present iff `BUZZ_ADMIN_HOST` is set and omitted otherwise. The
scheme follows the same loopback rule as NIP-98 `u`-tag verification via
a shared `scheme_for_host` helper, so the advertised origin and the
origin the relay verifies against can never diverge.

## Operator API origin decoupling

`RELAY_OPERATOR_API_ORIGIN` is no longer required at boot when
`RELAY_OPERATOR_PUBKEYS` is set — it is used only by the
community-provisioning endpoints, which fail closed at request time
(with a boot-time `WARN`) until it is set. The admin console needs no
origin.

## Admin-web adaptation

The standalone `admin-web` dashboard signs each request as a NIP-98
event via a NIP-07 browser extension, discovers the auth mode with a
single unauthenticated probe (`200` → `disabled`, anything else →
`nip98`, fail-secure), and carries no token entry surface. Playwright
coverage exercises the NIP-98 and CSP paths.

## Security hardening

Three findings from security review are folded in:

- **Append-only roster audit.** `PUT`/`DELETE /operators/{pubkey}`
mutate the deployment-wide root of trust, but the upsert overwrites
`role`/`added_by` in place and the delete removes the only row — so a
grant→revoke sequence left no trace of who was ever granted or by whom.
Each mutation now writes an `relay_operator_audit` row (actor, target,
`grant`/`revoke`, pre-image `prev_role`, `new_role`, timestamp) inside
the same transaction as the mutation. A per-target transaction-scoped
advisory lock serializes concurrent mutations of the same pubkey before
the pre-image read, so the recorded `prev_role` is always the true
predecessor even under a concurrent-grant race. Chronology is keyed on a
`BIGINT GENERATED ALWAYS AS IDENTITY` `seq` column, not the wall clock:
the serializing lock guarantees insertion order and `seq` captures it,
so ordered reads (`ORDER BY seq`) follow the true privilege chain even
across a backward NTP step that a `clock_timestamp()` ordering would
invert. `created_at` (`clock_timestamp()`) is retained as informational
occurrence time only. Append-only by construction — no `UPDATE`/`DELETE`
path and no API surface. A no-op delete writes nothing.
- **`expirationSecs` overflow.** The timeout path built `Utc::now() +
Duration::seconds(secs as i64)` from an attacker-controlled `u64`:
`i64::MAX` panicked the handler, and a wrapped-negative magnitude minted
a *past* expiry that still passed validation. `compute_timeout_until`
now rejects zero, rejects magnitudes above a documented
`MAX_TIMEOUT_SECS` (365 days), and uses checked
`try_seconds`/`checked_add_signed` so no input can panic or produce a
past expiry — over-cap, zero, `i64::MAX`, and wrapping-negative inputs
all return a clean `4xx`.
- **Uppercase-hex config-backed bypass.** Config pubkeys are lowercased
at parse, but the `409` immutability check raw-string-compared the path
param while `decode_hex_pubkey` accepted uppercase — so `PUT
/operators/{UPPERCASE}` skipped the guard and wrote a shadow row for the
same 32 bytes. The validated param is now canonicalized (lowercased)
before the `409` check, DB write, `DELETE`, and response body.

## Migrations

- `0035_relay_operators.sql` — `relay_operators` roster table
(deployment-global), `actor_authority` on `moderation_actions`,
`processing` status plus `active_action_id` on `moderation_reports`,
`status` on `product_feedback`.
- `0036_relay_admin_actions.sql` — enforcement-action table with a
`request_id` idempotency key, a `step_marker` for crash recovery, and a
`cancelled_by` attribution column.
- `0037_relay_admin_action_lease.sql` — lease fencing for the action
worker.
- `0038_relay_admin_outbox_claim_token.sql` — fenced claim token on the
outbox worker.
- `0039_relay_operator_audit.sql` — append-only `relay_operator_audit`
trail for roster mutations (see Security hardening).

`docs/admin/README.md` documents the full principal model, NIP-98 event
requirements, capabilities by role, the startup error matrix, and the
discovery field.

## Production blast radius

A relay without `BUZZ_ADMIN_HOST` is completely unaffected — the admin
surface stays disabled and `BUZZ_ADMIN_AUTH` is ignored; a lingering
`BUZZ_ADMIN_TOKEN` logs a startup warning and must be removed. Where
`BUZZ_ADMIN_HOST` **is** set, unset `BUZZ_ADMIN_AUTH` defaults to
`nip98` (per-person signed auth); `BUZZ_ADMIN_AUTH=disabled` reproduces
`main`'s prior `Host`/`Origin`-only gating but is read-only (mutation
routes `403`). The five migrations add tables and columns without
touching existing data.

---

Related: [block/buzz#4768](https://github.com/block/buzz/pull/4768)
(desktop admin console consuming the `admin_api` field),
[squareup/bb-public#339](https://github.com/squareup/bb-public/pull/339)
(Phase 4 rollout config)

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-28 11:43:05 -04:00

767 lines
12 KiB
CSS

:root {
font-family:
"Helvetica Neue", Helvetica, Arial, ui-sans-serif, system-ui, sans-serif;
color: #231e1e;
background: #d7e7f6;
font-synthesis: none;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
a {
color: inherit;
text-decoration: none;
}
button {
font: inherit;
}
input,
select {
font: inherit;
}
button {
color: white;
background: #231e1e;
border: 0;
border-radius: 999px;
padding: 0.75rem 1.25rem;
cursor: pointer;
}
.app {
min-height: 100vh;
background: linear-gradient(180deg, #d7d72e 0%, #dfe379 24%, #d7e7f6 78%);
}
.app-header {
width: min(1120px, calc(100% - 3rem));
margin: 0 auto;
padding: 1.5rem 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 2rem;
}
.brand {
display: inline-flex;
align-items: center;
gap: 0.75rem;
font-size: 1.1rem;
font-weight: 500;
letter-spacing: -0.04em;
}
.brand b {
font-weight: 400;
color: rgb(35 30 30 / 60%);
}
.brand-mark {
width: 2.5rem;
height: 2.5rem;
display: grid;
place-items: center;
border-radius: 0.7rem;
color: #d7d72e;
background: #231e1e;
}
.brand-mark svg {
width: 1.7rem;
fill: currentColor;
}
nav {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem;
border: 1px solid rgb(35 30 30 / 10%);
border-radius: 999px;
background: rgb(255 255 255 / 42%);
backdrop-filter: blur(12px);
}
.nav-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.65rem 1rem;
border-radius: 999px;
color: rgb(35 30 30 / 60%);
font-size: 0.9rem;
letter-spacing: -0.035em;
transition:
color 160ms ease,
background 160ms ease;
}
.nav-link svg {
width: 1rem;
height: 1rem;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.nav-link:hover {
color: #231e1e;
}
.nav-link[aria-current="page"] {
color: #231e1e;
background: white;
box-shadow: 0 0.2rem 1rem rgb(35 30 30 / 8%);
}
main {
width: min(1120px, calc(100% - 3rem));
margin: 0 auto;
padding: 4.5rem 0 7rem;
}
.page-title {
max-width: 52rem;
margin-bottom: 3rem;
}
.page-title > p {
margin: 0 0 1rem;
color: rgb(35 30 30 / 55%);
font-size: 0.78rem;
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: clamp(3rem, 7vw, 5.5rem);
font-weight: 400;
line-height: 0.92;
letter-spacing: -0.065em;
}
.page-title > span {
display: block;
margin-top: 1.5rem;
color: rgb(35 30 30 / 62%);
font-size: 1.05rem;
line-height: 1.5;
letter-spacing: -0.035em;
}
.back-link {
width: fit-content;
display: flex;
align-items: center;
gap: 0.35rem;
margin-bottom: 2.25rem;
color: rgb(35 30 30 / 62%);
font-size: 0.9rem;
letter-spacing: -0.035em;
}
.cards {
display: grid;
gap: 0.8rem;
}
.feedback-filters {
display: grid;
grid-template-columns: minmax(14rem, 1fr) repeat(3, auto);
gap: 0.75rem;
align-items: end;
margin-bottom: 0.75rem;
padding: 1rem;
border: 1px solid rgb(35 30 30 / 10%);
border-radius: 1.25rem;
background: rgb(255 255 255 / 45%);
backdrop-filter: blur(12px);
}
.feedback-filters label {
display: grid;
gap: 0.45rem;
min-width: 0;
}
.feedback-filters label > span {
color: rgb(35 30 30 / 48%);
font-size: 0.7rem;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.feedback-filters input,
.feedback-filters select {
min-height: 2.75rem;
border: 1px solid rgb(35 30 30 / 12%);
border-radius: 999px;
outline: none;
color: #231e1e;
background: white;
}
.feedback-filters input:focus,
.feedback-filters select:focus {
border-color: #231e1e;
box-shadow: 0 0 0 3px rgb(35 30 30 / 10%);
}
.feedback-filters select {
width: 100%;
min-width: 0;
padding: 0 2.25rem 0 1rem;
}
.search-field > div {
position: relative;
}
.search-field input {
width: 100%;
padding: 0 1rem 0 2.6rem;
}
.search-field svg {
position: absolute;
z-index: 1;
top: 50%;
left: 1rem;
width: 1rem;
height: 1rem;
transform: translateY(-50%);
fill: none;
stroke: rgb(35 30 30 / 45%);
stroke-width: 1.7;
stroke-linecap: round;
}
.result-count {
margin: 0 0 0.75rem;
color: rgb(35 30 30 / 48%);
font-size: 0.78rem;
}
.card-link {
display: block;
border-radius: 1.6rem;
}
.record-card,
.detail,
.state {
background: white;
border-radius: 1.6rem;
box-shadow: 0 1rem 4rem rgb(35 30 30 / 6%);
}
.record-card {
min-height: 7.2rem;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 1.4rem;
padding: 1.2rem 1.5rem;
transition:
transform 180ms ease,
box-shadow 180ms ease;
}
.feedback-record {
grid-template-columns: minmax(0, 1fr) auto auto auto;
}
.feedback-main-link {
min-width: 0;
display: flex;
align-items: center;
gap: 1.4rem;
}
.record-open-link:hover {
text-decoration: underline;
text-underline-offset: 0.2rem;
}
.feedback-status {
min-width: 5rem;
display: flex;
align-items: center;
gap: 0.4rem;
color: rgb(35 30 30 / 62%);
font-size: 0.78rem;
}
.feedback-status select {
font: inherit;
padding: 0.25rem 0.4rem;
border-radius: 0.4rem;
border: 1px solid rgb(35 30 30 / 18%);
background: #fff;
color: #231e1e;
cursor: pointer;
}
.feedback-status select:disabled {
opacity: 0.55;
cursor: progress;
}
.status-error {
color: #b3261e;
font-size: 0.72rem;
}
.provenance-unavailable {
font-style: italic;
color: rgb(35 30 30 / 45%);
}
.record-open-link {
display: grid;
place-items: center;
padding: 0.5rem;
border-radius: 999px;
color: rgb(35 30 30 / 30%);
}
.card-link:hover .record-card {
transform: translateY(-2px);
box-shadow: 0 1.4rem 4.5rem rgb(35 30 30 / 12%);
}
.record-icon {
width: 3rem;
height: 3rem;
display: grid;
place-items: center;
border-radius: 999px;
color: #231e1e;
}
.report-icon {
background: #d7d72e;
}
.feedback-icon {
background: #d7e7f6;
}
.record-icon svg {
width: 1.35rem;
height: 1.35rem;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.record-primary {
min-width: 0;
display: grid;
justify-items: start;
gap: 0.35rem;
}
.tag,
.status {
display: inline-flex;
align-items: center;
border-radius: 999px;
background: #f2f2ed;
padding: 0.25rem 0.55rem;
color: rgb(35 30 30 / 65%);
font-size: 0.7rem;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.tag {
gap: 0.3rem;
}
.tag svg {
width: 0.78rem;
height: 0.78rem;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.record-primary strong {
max-width: 42rem;
overflow: hidden;
color: #231e1e;
font-size: 1rem;
font-weight: 400;
line-height: 1.35;
letter-spacing: -0.04em;
text-overflow: ellipsis;
white-space: nowrap;
}
.feedback-card .record-primary strong {
display: -webkit-box;
overflow: hidden;
line-height: 1.35;
white-space: normal;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.record-provenance {
display: flex;
align-items: center;
gap: 0.65rem;
color: rgb(35 30 30 / 55%);
font-size: 0.78rem;
}
code {
color: rgb(35 30 30 / 50%);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.75rem;
word-break: break-all;
}
.record-provenance code {
padding-left: 0.65rem;
border-left: 1px solid rgb(35 30 30 / 14%);
}
.record-date {
display: grid;
justify-items: end;
gap: 0.3rem;
color: rgb(35 30 30 / 48%);
font-size: 0.78rem;
letter-spacing: -0.025em;
}
.record-date span {
color: rgb(35 30 30 / 32%);
font-size: 0.68rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.arrow-icon {
width: 1.25rem;
height: 1.25rem;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.back-link .arrow-icon {
transform: rotate(180deg);
}
.record-card > .arrow-icon {
color: rgb(35 30 30 / 30%);
}
.detail {
padding: 2rem;
}
.detail-heading {
display: flex;
align-items: center;
gap: 1rem;
padding-bottom: 2rem;
border-bottom: 1px solid rgb(35 30 30 / 9%);
}
.detail-heading h2 {
margin: 0.45rem 0 0;
font-size: 1.5rem;
font-weight: 400;
letter-spacing: -0.05em;
}
dl {
display: grid;
grid-template-columns: 9rem minmax(0, 1fr);
gap: 1.4rem;
margin: 2rem 0 0;
}
dt {
color: rgb(35 30 30 / 48%);
font-size: 0.82rem;
}
dd {
min-width: 0;
margin: 0;
}
.reported-message {
display: grid;
justify-items: start;
gap: 1rem;
border-left: 3px solid #d7d72e;
border-radius: 0 0.8rem 0.8rem 0;
background: #f6f6f1;
padding: 1rem;
}
.reported-message p,
.message-unavailable {
margin: 0;
overflow-wrap: anywhere;
line-height: 1.5;
white-space: pre-wrap;
}
.reported-message-meta {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.4rem 0.75rem;
width: 100%;
color: rgb(35 30 30 / 48%);
font-size: 0.78rem;
}
.message-unavailable {
color: rgb(35 30 30 / 60%);
font-style: italic;
}
.sensitive {
border-left: 3px solid #d7d72e;
border-radius: 0 0.8rem 0.8rem 0;
background: #f6f6f1;
padding: 1rem;
line-height: 1.5;
}
.feedback-body {
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.attachments {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
gap: 0.75rem;
align-items: start;
}
.image-attachment {
overflow: hidden;
margin: 0;
border: 1px solid rgb(35 30 30 / 10%);
border-radius: 1rem;
background: #f6f6f1;
}
.image-attachment a {
display: block;
}
.image-attachment img {
width: 100%;
max-height: 32rem;
display: block;
object-fit: contain;
background: rgb(35 30 30 / 4%);
}
.image-attachment figcaption,
.file-attachment {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.85rem 1rem;
}
.image-attachment figcaption {
justify-content: space-between;
}
.image-attachment figcaption span,
.file-attachment strong {
min-width: 0;
overflow: hidden;
font-size: 0.82rem;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.feedback-main-link:hover strong {
text-decoration: underline;
text-underline-offset: 0.2rem;
}
.image-attachment small,
.file-attachment small {
color: rgb(35 30 30 / 48%);
font-size: 0.7rem;
}
.file-attachment {
align-self: start;
border: 1px solid rgb(35 30 30 / 10%);
border-radius: 1rem;
background: #f6f6f1;
}
.file-attachment:hover strong {
text-decoration: underline;
text-underline-offset: 0.15rem;
}
.file-attachment > svg:first-child {
width: 1.5rem;
height: 1.5rem;
flex: none;
fill: none;
stroke: currentColor;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.file-attachment > span {
min-width: 0;
display: grid;
flex: 1;
gap: 0.2rem;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
.state {
min-height: 14rem;
display: grid;
place-content: center;
justify-items: center;
padding: 2rem;
color: rgb(35 30 30 / 56%);
text-align: center;
letter-spacing: -0.035em;
}
.state h2 {
margin: 0;
color: #231e1e;
font-size: 1.4rem;
font-weight: 400;
}
.state p {
margin: 0.75rem 0 1.25rem;
}
.state.error {
color: #9f2424;
}
.auth-prompt button {
margin-top: 1rem;
}
.attachment-placeholder {
min-height: 8rem;
display: grid;
place-content: center;
color: rgb(35 30 30 / 48%);
font-size: 0.8rem;
}
@media (max-width: 720px) {
.app-header {
width: min(100% - 2rem, 1120px);
align-items: flex-start;
flex-direction: column;
gap: 1rem;
}
main {
width: min(100% - 2rem, 1120px);
padding-top: 3rem;
}
h1 {
font-size: clamp(3rem, 16vw, 4.5rem);
}
.record-card {
grid-template-columns: auto minmax(0, 1fr);
}
.feedback-filters {
grid-template-columns: 1fr;
}
.feedback-record {
grid-template-columns: minmax(0, 1fr) auto;
}
.feedback-main-link {
grid-column: 1 / -1;
}
.feedback-status {
grid-column: 1;
flex-wrap: wrap;
}
.feedback-record .record-date {
grid-column: 1;
}
.record-open-link {
grid-column: 2;
grid-row: 2 / span 2;
}
.record-date {
grid-column: 2;
justify-items: start;
}
.record-card > .arrow-icon {
display: none;
}
dl {
grid-template-columns: 1fr;
gap: 0.6rem;
}
dd + dt {
margin-top: 0.9rem;
}
}