/* =====================================================
   TehGuys Design-System Component Layer
   Reusable UI primitives built ONLY on theme.css tokens.
   No hardcoded colors or spacing — everything via var(--...).
   ===================================================== */

/* =====================================================
   Card — .ds-card + header / body / footer
   ===================================================== */
.ds-card {
  background: var(--bg-card);
  border: 1px solid var(--border-medium);
  border-radius: var(--radius-lg);
  padding: var(--space-lg);
  box-shadow: var(--shadow-card);
  color: var(--text-primary);
  transition: box-shadow var(--transition-base), border-color var(--transition-base);
}

.ds-card__header {
  font-weight: 600;
  font-size: var(--font-size-lg);
  color: var(--text-primary);
  margin-bottom: var(--space-md);
  padding-bottom: var(--space-md);
  border-bottom: 1px solid var(--border-dark);
}

.ds-card__body {
  color: var(--text-secondary);
  font-size: var(--font-size-base);
}

.ds-card__footer {
  margin-top: var(--space-md);
  padding-top: var(--space-md);
  border-top: 1px solid var(--border-dark);
  display: flex;
  gap: var(--space-sm);
  align-items: center;
}

/* Interactive card + display title. Moved here from
   frontend/src/styles/tokens.css in P0.2 — these two were defined ONLY in
   the island bundle and are consumed by frontend/src/components/ui/Card.tsx
   (`hover` prop and `CardTitle`), so they had to move rather than be
   dropped. Living here also makes them available to server templates. */
.ds-card--hover,
.ds-card--interactive {
  cursor: pointer;
  transition: transform 200ms ease, box-shadow 200ms ease;
}

.ds-card--hover:hover,
.ds-card--interactive:hover {
  transform: translateY(-2px);
  box-shadow: var(--shadow-card-hover);
}

.ds-card__title {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1.125rem;
  line-height: 1.2;
}

/* Variants (B1). Four of them, sharing ONE radius scale and ONE elevation
   scale, so a card never invents a corner or a shadow of its own:

     variant       radius        rest elevation   hover elevation
     default       --radius-lg   --shadow-card    -
     raised        --radius-lg   --shadow-lg      -
     inset         --radius-md   none             -
     interactive   --radius-lg   --shadow-card    --shadow-card-hover

   `inset` is the nested rung: it sits INSIDE another card, so it steps down
   one radius and drops its shadow. Two stacked elevations read as one
   blurred edge, and a nested corner larger than its parent's looks broken.

   .ds-card--hover is the older name for the same interactive treatment and
   is what frontend/src/components/ui/Card.tsx already emits for its `hover`
   prop. Both names carry identical declarations above rather than one
   aliasing the other, so the two stacks cannot drift. New call sites use
   --interactive; --hover is kept only so the React prop keeps working.

   Whole-card click targets must not nest another interactive target - see
   templates/includes/_card.html for why. The focus ring below is not
   decoration: when the card IS the control there is no inner button to
   borrow an indicator from, so this rule is the only thing a keyboard user
   sees. */
.ds-card--raised {
  box-shadow: var(--shadow-lg);
}

.ds-card--inset {
  border-radius: var(--radius-md);
  border-color: var(--border-light);
  background: var(--surface-light);
  box-shadow: none;
}

.ds-card--interactive {
  display: block;
  color: inherit;
  text-decoration: none;
}

.ds-card--interactive:focus-visible {
  outline: 2px solid var(--focus-ring, #1E8F4E);
  outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce) {
  .ds-card {
    transition: none;
  }

  .ds-card--hover,
  .ds-card--interactive {
    transition: none;
  }

  .ds-card--hover:hover,
  .ds-card--interactive:hover {
    transform: none;
  }
}

/* =====================================================
   Button — .ds-btn + variants + sizes + states
   ===================================================== */
.ds-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-sm);
  padding: var(--space-sm) var(--space-lg);
  border: 1px solid transparent;
  border-radius: var(--radius-md);
  font-family: var(--font-family);
  font-size: var(--font-size-sm);
  font-weight: 600;
  line-height: 1.2;
  cursor: pointer;
  text-decoration: none;
  white-space: nowrap;
  -webkit-appearance: none;
  appearance: none;
  transition: background var(--transition-fast), border-color var(--transition-fast),
    color var(--transition-fast), box-shadow var(--transition-fast);
}

.ds-btn:focus-visible {
  outline: none;
  box-shadow: 0 0 0 3px var(--accent-blue-glow);
}

.ds-btn:disabled,
.ds-btn[aria-disabled="true"] {
  opacity: 0.55;
  cursor: not-allowed;
  pointer-events: none;
}

/* Sizes */
.ds-btn--sm {
  padding: var(--space-xs) var(--space-md);
  font-size: var(--font-size-xs);
  border-radius: var(--radius-sm);
}

.ds-btn--lg {
  padding: var(--space-md) var(--space-xl);
  font-size: var(--font-size-base);
  border-radius: var(--radius-lg);
}

/* Variants */
.ds-btn--primary {
  background: var(--accent-blue);
  /* Ink on light accents (Electric Green); white on dark accents (Indigo /
     Dark Purple). Per-palette --accent-contrast keeps the label readable. */
  color: var(--accent-contrast, #0E2233);
  border-color: var(--accent-blue);
}

.ds-btn--primary:hover {
  background: var(--accent-blue-hover);
  border-color: var(--accent-blue-hover);
  color: var(--accent-contrast, #0E2233);
}

.ds-btn--secondary {
  background: var(--surface-light);
  color: var(--text-primary);
  border-color: var(--border-medium);
}

.ds-btn--secondary:hover {
  background: var(--surface-highlight);
  border-color: var(--border-light);
  color: var(--text-primary);
}

.ds-btn--ghost {
  background: rgba(83, 232, 103, 0.04);
  color: var(--text-secondary);
  border-color: var(--border-medium);
}

.ds-btn--ghost:hover {
  background: var(--surface-light);
  color: var(--text-primary);
  border-color: var(--border-light);
}

.ds-btn--danger {
  background: var(--accent-red);
  color: #fff;
  border-color: var(--accent-red);
}

.ds-btn--danger:hover {
  background: var(--accent-red);
  border-color: var(--accent-red);
  color: #fff;
  filter: brightness(0.92);
}

/* Accent (craft pink) — used for the profile Message button so it stays
   visually distinct from the indigo primary Subscribe button beside it. */
.ds-btn--accent {
  background: var(--craft-pink);
  color: #fff;
  border-color: var(--craft-pink);
}

.ds-btn--accent:hover {
  background: var(--craft-pink);
  border-color: var(--craft-pink);
  color: #fff;
  filter: brightness(0.94);
}

/* ── Alert (token-styled message box) ──────────────────────────────────── */
.ds-alert {
  display: flex;
  gap: var(--space-sm);
  align-items: flex-start;
  padding: var(--space-sm) var(--space-md);
  border-radius: var(--radius-md);
  border: 1px solid var(--border-medium);
  background: var(--surface-light);
  color: var(--text-secondary);
  font-size: 0.9rem;
}
.ds-alert--info {
  border-color: var(--craft-indigo);
  background: var(--craft-indigo-tint);
  color: var(--craft-indigo);
}
.ds-alert--success {
  border-color: var(--accent-green);
  background: color-mix(in srgb, var(--accent-green) 10%, transparent);
  color: var(--accent-green);
}
.ds-alert--warning {
  border-color: var(--accent-yellow);
  background: color-mix(in srgb, var(--accent-yellow) 12%, transparent);
  color: var(--text-primary);
}
.ds-alert--danger {
  border-color: var(--accent-red);
  background: var(--craft-pink-tint);
  color: var(--accent-red);
}

/* ── Tabs (token-styled tab row) ───────────────────────────────────────── */
.ds-tabs {
  display: flex;
  gap: var(--space-xs);
  border-bottom: 2px solid var(--border-dark);
  overflow-x: auto;
  scrollbar-width: none;
}
.ds-tabs::-webkit-scrollbar {
  display: none;
}
.ds-tab {
  display: inline-flex;
  align-items: center;
  gap: var(--space-xs);
  padding: var(--space-sm) var(--space-md);
  background: none;
  border: none;
  border-bottom: 2px solid transparent;
  margin-bottom: -2px;
  font-size: 0.9rem;
  font-weight: 500;
  color: var(--text-muted);
  cursor: pointer;
  white-space: nowrap;
  text-decoration: none;
  transition: color 0.15s, border-color 0.15s;
}
.ds-tab:hover {
  color: var(--text-primary);
}
.ds-tab.is-active,
.ds-tab[aria-selected="true"] {
  color: var(--craft-indigo);
  border-bottom-color: var(--craft-indigo);
  font-weight: 600;
}

/* Mobile touch-target floor, same rule the button contract holds (C3). A tab
   is a control people tap, and this one had no min-height at all: measured in
   a browser at 390px, all nine .ds-tab elements came out 34px tall - 8px of
   padding either side of a 14.4px line box - which is 10px under the 44px
   floor (WCAG 2.5.5 / 2.5.8). Both axes are set, because a floor on one axis
   only is the exact half-fix the sm button carried above.
   Deliberately NOT applied at desktop widths: a pointer is precise, the row
   scrolls horizontally, and 44px tall tabs would push the content of every
   tabbed page down by a third of a row for no accessibility gain. */
@media (max-width: 639px) {
  .ds-tab {
    min-height: 44px;
    min-width: 44px;
    justify-content: center;
  }
}

@media (prefers-reduced-motion: reduce) {
  .ds-btn {
    transition: none;
  }
}

/* =====================================================
   Field — .ds-field (label, input, help, error)
   ===================================================== */
.ds-field {
  display: flex;
  flex-direction: column;
  gap: var(--space-xs);
}

.ds-field__label {
  font-size: var(--font-size-sm);
  font-weight: 500;
  color: var(--text-secondary);
}

/* Required / optional marker inside the label (B2). The `required` ATTRIBUTE
   on the input is what assistive tech announces, so the visual asterisk is
   aria-hidden and colour is never the only cue; "(optional)" has no
   equivalent attribute and is therefore plain readable text. */
.ds-field__marker {
  margin-left: 0.25ch;
  color: var(--text-muted);
  font-weight: 400;
}

.ds-field__input {
  width: 100%;
  padding: var(--space-md);
  background: var(--bg-input);
  color: var(--text-primary);
  border: 1px solid var(--border-medium);
  border-radius: var(--radius-md);
  font-family: var(--font-family);
  font-size: var(--font-size-base);
  transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}

.ds-field__input::placeholder {
  color: var(--text-placeholder);
}

.ds-field__input:focus,
.ds-field__input:focus-visible {
  outline: none;
  border-color: var(--accent-blue);
  box-shadow: 0 0 0 3px var(--accent-blue-glow);
}

.ds-field__help {
  font-size: var(--font-size-xs);
  color: var(--text-muted);
}

.ds-field__error {
  font-size: var(--font-size-xs);
  color: var(--accent-red);
  font-weight: 500;
}

/* Invalid state */
.ds-field--invalid .ds-field__input {
  border-color: var(--accent-red);
}

.ds-field--invalid .ds-field__input:focus,
.ds-field--invalid .ds-field__input:focus-visible {
  box-shadow: 0 0 0 3px var(--accent-blue-glow);
  border-color: var(--accent-red);
}

@media (prefers-reduced-motion: reduce) {
  .ds-field__input {
    transition: none;
  }
}

/* =====================================================
   Empty state — .ds-empty (icon, title, subtitle, action)
   ===================================================== */
.ds-empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  gap: var(--space-md);
  padding: var(--space-2xl) var(--space-lg);
  color: var(--text-secondary);
}

.ds-empty__icon {
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--avatar-lg);
  height: var(--avatar-lg);
  border-radius: var(--radius-full);
  background: var(--surface-light);
  color: var(--text-muted);
  font-size: var(--font-size-4xl);
}

.ds-empty__title {
  font-size: var(--font-size-xl);
  font-weight: 700;
  color: var(--text-primary);
  margin: 0;
}

.ds-empty__subtitle {
  font-size: var(--font-size-sm);
  color: var(--text-muted);
  max-width: 40ch;
  margin: 0;
}

.ds-empty__action {
  margin-top: var(--space-sm);
}

/* State variants (B3). Empty, loading and error are the same surface, not
   three unrelated blocks: a list that swaps between a skeleton, a red panel
   and a centred illustration as its data resolves reads as three different
   pages. Only the icon treatment changes here; the live-region role and the
   next action are set in templates/includes/_empty_state.html.

   The spinner reuses the button contract's btn-spin keyframes rather than
   declaring a second identical rotation - one spin definition, so the two
   spinners cannot drift apart or be silenced separately. */
.ds-empty--error .ds-empty__icon {
  color: var(--accent-red);
}

.ds-empty__spinner {
  width: 28px;
  height: 28px;
  border: 3px solid var(--border-medium);
  border-top-color: var(--craft-indigo);
  border-radius: var(--radius-full);
  animation: btn-spin 0.8s linear infinite;
}

@media (prefers-reduced-motion: reduce) {
  .ds-empty__spinner {
    animation: none;
    opacity: 0.6;
  }
}

/* =====================================================
   Avatar — .ds-avatar (sizes sm/md/lg, circle, fallback)
   ===================================================== */
.ds-avatar {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--avatar-md);
  height: var(--avatar-md);
  border-radius: var(--radius-full);
  background: var(--gradient-primary);
  color: var(--accent-contrast, #0E2233);
  font-weight: 600;
  font-size: var(--font-size-base);
  overflow: hidden;
  flex-shrink: 0;
  text-transform: uppercase;
  line-height: 1;
}

.ds-avatar img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
}

.ds-avatar--sm {
  width: var(--avatar-sm);
  height: var(--avatar-sm);
  font-size: var(--font-size-sm);
}

.ds-avatar--lg {
  width: var(--avatar-lg);
  height: var(--avatar-lg);
  font-size: var(--font-size-3xl);
}

/* =====================================================
   Badge — .ds-badge (default/success/warning/danger + dot)
   ===================================================== */
.ds-badge {
  display: inline-flex;
  align-items: center;
  gap: var(--space-xs);
  padding: var(--space-xs) var(--space-sm);
  border-radius: var(--radius-full);
  font-size: var(--font-size-xs);
  font-weight: 600;
  line-height: 1;
  background: var(--surface-light);
  color: var(--text-secondary);
  border: 1px solid var(--border-medium);
}

.ds-badge--success {
  background: var(--accent-green);
  color: #fff;
  border-color: var(--accent-green);
}

.ds-badge--warning {
  background: var(--accent-yellow);
  color: var(--text-primary);
  border-color: var(--accent-yellow);
}

.ds-badge--danger {
  background: var(--accent-red);
  color: #fff;
  border-color: var(--accent-red);
}

/* Leading status dot */
.ds-badge__dot {
  display: inline-block;
  width: var(--space-sm);
  height: var(--space-sm);
  border-radius: var(--radius-full);
  background: currentColor;
}

/* Craft-palette variants (moved here from frontend/src/styles/tokens.css in
   P0.2 — they previously shipped only inside the island bundle, so they had
   no effect on any server-rendered page that mounted no island). */
/* Text is --text-primary, not the accent colour, on all three craft-tint
   badges. Accent-on-its-own-tint fails WCAG AA (4.5:1) at this size and
   weight: indigo measured 3.92:1 on --bg-card and 3.15:1 in dark mode,
   pink 4.11:1 / 3.17:1, and orange 1.93:1 — effectively illegible. The
   tint and border already carry the categorical signal, so the label does
   not need to repeat it in colour, and --text-primary is per-palette so
   this holds across all 14 themes rather than only the default one.
   These badges became reachable from server templates in P0.2 and the
   disclosure partial exposes badge_variant as a public knob, so the
   pre-existing values would otherwise have propagated. */
.ds-badge--indigo {
  background: var(--craft-indigo-tint);
  color: var(--text-primary);
  border: 1px solid color-mix(in srgb, var(--craft-indigo) 20%, transparent);
}

.ds-badge--pink {
  background: var(--craft-pink-tint);
  color: var(--text-primary);
  border: 1px solid rgba(236, 72, 153, 0.20);
}

.ds-badge--orange {
  background: var(--craft-orange-tint);
  color: var(--text-primary);
  border: 1px solid rgba(249, 115, 22, 0.20);
}

/* Event-type variants (mirror of eventColors in frontend/src/tokens.ts) */
.ds-badge--sports {
  background: #fff7ed;
  color: #c2410c;
  border: 1px solid #fed7aa;
}

.ds-badge--arts {
  background: #f5f3ff;
  color: #7c3aed;
  border: 1px solid #ddd6fe;
}

.ds-badge--social {
  background: #fdf2f8;
  color: #be185d;
  border: 1px solid #fbcfe8;
}

.ds-badge--outdoor {
  background: #f0fdf4;
  color: #15803d;
  border: 1px solid #bbf7d0;
}

.ds-badge--food {
  background: #fffbeb;
  color: #b45309;
  border: 1px solid #fde68a;
}

.ds-badge--other {
  background: #f8fafc;
  color: #475569;
  border: 1px solid #e2e8f0;
}

/* =====================================================
   Button variant — Warning (.ds-btn--warning)
   Amber/orange surface; dark text for WCAG contrast.
   Mirrors .btn-warning in social_platform.css.
   ===================================================== */
.ds-btn--warning {
  background: var(--accent-yellow);
  color: var(--text-primary);
  border-color: var(--accent-yellow);
}

.ds-btn--warning:hover {
  background: var(--craft-orange);
  border-color: var(--craft-orange);
  color: var(--text-primary);
  filter: brightness(0.97);
}

/* =====================================================
   Modal — .ds-modal system
   Mirrors Bootstrap .modal-content / .modal-header /
   .modal-body / .modal-footer look from social_platform.css.
   ===================================================== */
.ds-modal__overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
  /* Hidden by default; shown by JS via .is-open / [data-open] / .show, or kept
     visible in static demos by adding one of those. The [hidden] attribute
     (used by some templates' onclick toggles) always wins. */
  display: none;
  align-items: center;
  justify-content: center;
  z-index: 1050;
  padding: var(--space-md);
}
.ds-modal__overlay.is-open,
.ds-modal__overlay[data-open],
.ds-modal__overlay.show {
  display: flex;
}
.ds-modal__overlay[hidden] {
  display: none;
}

.ds-modal {
  background: var(--bg-card);
  border: 1px solid var(--border-medium);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-xl);
  width: 100%;
  max-width: 540px;
  max-height: 90vh;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  color: var(--text-primary);
}

.ds-modal__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: var(--space-lg) var(--space-xl);
  border-bottom: 1px solid var(--border-dark);
  font-weight: 600;
  font-size: var(--font-size-lg);
  color: var(--text-primary);
  flex-shrink: 0;
}

.ds-modal__title {
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--font-size-lg);
  font-weight: 700;
  color: var(--text-primary);
}

.ds-modal__body {
  padding: var(--space-xl);
  color: var(--text-primary);
  flex: 1;
}

.ds-modal__footer {
  padding: var(--space-md) var(--space-xl);
  border-top: 1px solid var(--border-dark);
  display: flex;
  gap: var(--space-sm);
  justify-content: flex-end;
  flex-shrink: 0;
}

.ds-modal__close {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  border-radius: var(--radius-sm);
  background: none;
  border: none;
  color: var(--text-muted);
  cursor: pointer;
  font-size: var(--font-size-lg);
  line-height: 1;
  transition: background var(--transition-fast), color var(--transition-fast);
  flex-shrink: 0;
}

.ds-modal__close:hover {
  background: var(--surface-light);
  color: var(--text-primary);
}

/* =====================================================
   Dropdown — .ds-dropdown system
   Mirrors .dropdown-menu / .dropdown-item from theme.css.
   ===================================================== */
.ds-dropdown {
  position: relative;
  display: inline-block;
}

.ds-dropdown__menu {
  position: absolute;
  top: calc(100% + var(--space-xs));
  left: 0;
  min-width: 180px;
  background: var(--bg-card);
  border: 1px solid var(--border-medium);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-lg);
  padding: var(--space-xs);
  z-index: 200;
  display: none;
}

.ds-dropdown__menu.is-open,
.ds-dropdown__menu[data-open] {
  display: block;
}

.ds-dropdown__item {
  display: block;
  width: 100%;
  padding: var(--space-sm) var(--space-md);
  border-radius: var(--radius-sm);
  background: none;
  border: none;
  color: var(--text-secondary);
  font-size: var(--font-size-sm);
  font-family: var(--font-family);
  text-align: left;
  text-decoration: none;
  cursor: pointer;
  transition: background var(--transition-fast), color var(--transition-fast);
  white-space: nowrap;
}

.ds-dropdown__item:hover,
.ds-dropdown__item:focus {
  background: var(--surface-light);
  color: var(--text-primary);
}

/* =====================================================
   List — .ds-list system
   Mirrors Bootstrap .list-group / .list-group-item
   and the social_platform.css overrides.
   ===================================================== */
.ds-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
}

.ds-list__item {
  background: var(--bg-card);
  border: 1px solid var(--border-medium);
  color: var(--text-primary);
  padding: var(--space-md) var(--space-lg);
  margin-top: -1px; /* collapse adjacent borders */
}

.ds-list__item:first-child {
  border-radius: var(--radius-md) var(--radius-md) 0 0;
  margin-top: 0;
}

.ds-list__item:last-child {
  border-radius: 0 0 var(--radius-md) var(--radius-md);
}

.ds-list__item:only-child {
  border-radius: var(--radius-md);
}

.ds-list__item--action {
  cursor: pointer;
  transition: background var(--transition-fast), color var(--transition-fast);
  text-decoration: none;
  display: block;
}

.ds-list__item--action:hover {
  background: var(--surface-light);
  color: var(--text-primary);
}

/* =====================================================
   Table — .ds-table system
   Mirrors .table / .table thead th / .table tbody td
   from social_platform.css.
   ===================================================== */
.ds-table {
  width: 100%;
  border-collapse: collapse;
  color: var(--text-primary);
  font-size: var(--font-size-base);
}

.ds-table th {
  background: var(--surface-dark);
  border-bottom: 1px solid var(--border-medium);
  color: var(--text-secondary);
  font-weight: 600;
  padding: var(--space-md) var(--space-lg);
  text-align: left;
}

.ds-table td {
  border-bottom: 1px solid var(--border-medium);
  padding: var(--space-md) var(--space-lg);
  vertical-align: middle;
  color: var(--text-primary);
}

.ds-table tbody tr:last-child td {
  border-bottom: none;
}

/* Striped variant — alternating row bg-surface-light */
.ds-table--striped tbody tr:nth-child(even) {
  background: var(--surface-light);
}

/* =====================================================
   Responsive 12-column grid — .ds-row / .ds-col-*
   Pixel-equivalent to Bootstrap's .row / .col-* grid
   (same breakpoints, same 12-col %, same gutter math).
   .ds-col-{n} → {n}/12 * 100%
   .ds-col-{bp}-{n} → responsive variant
   .ds-g-{0..5} → gutter steps (mirrors Bootstrap g-*)
   ===================================================== */
/* Base gutter size (mirrors Bootstrap $grid-gutter-width = 1.5rem → ±0.75rem) */
:root {
  --ds-gutter: 0.75rem;
}

/* Centered page container (mirrors Bootstrap .container) */
.ds-container {
  width: 100%;
  max-width: var(--content-max-width, 1400px);
  margin-inline: auto;
  padding-inline: var(--container-pad, clamp(16px, 4vw, 48px));
}
.ds-container--fluid {
  max-width: none;
}

.ds-row {
  display: flex;
  flex-wrap: wrap;
  margin-inline: calc(-1 * var(--ds-gutter));
}

.ds-row > * {
  box-sizing: border-box;
  padding-inline: var(--ds-gutter);
  width: 100%;
}

.ds-col   { flex: 1 0 0%; }

/* --- Fixed-width columns (mobile-first: all apply at any width) --- */
.ds-col-1  { width:  8.3333%; }
.ds-col-2  { width: 16.6667%; }
.ds-col-3  { width: 25%; }
.ds-col-4  { width: 33.3333%; }
.ds-col-5  { width: 41.6667%; }
.ds-col-6  { width: 50%; }
.ds-col-7  { width: 58.3333%; }
.ds-col-8  { width: 66.6667%; }
.ds-col-9  { width: 75%; }
.ds-col-10 { width: 83.3333%; }
.ds-col-11 { width: 91.6667%; }
.ds-col-12 { width: 100%; }

/* --- sm breakpoint (≥576px) --- */
@media (min-width: 576px) {
  .ds-col-sm-1  { width:  8.3333%; }
  .ds-col-sm-2  { width: 16.6667%; }
  .ds-col-sm-3  { width: 25%; }
  .ds-col-sm-4  { width: 33.3333%; }
  .ds-col-sm-5  { width: 41.6667%; }
  .ds-col-sm-6  { width: 50%; }
  .ds-col-sm-7  { width: 58.3333%; }
  .ds-col-sm-8  { width: 66.6667%; }
  .ds-col-sm-9  { width: 75%; }
  .ds-col-sm-10 { width: 83.3333%; }
  .ds-col-sm-11 { width: 91.6667%; }
  .ds-col-sm-12 { width: 100%; }
}

/* --- md breakpoint (≥768px) --- */
@media (min-width: 768px) {
  .ds-col-md-1  { width:  8.3333%; }
  .ds-col-md-2  { width: 16.6667%; }
  .ds-col-md-3  { width: 25%; }
  .ds-col-md-4  { width: 33.3333%; }
  .ds-col-md-5  { width: 41.6667%; }
  .ds-col-md-6  { width: 50%; }
  .ds-col-md-7  { width: 58.3333%; }
  .ds-col-md-8  { width: 66.6667%; }
  .ds-col-md-9  { width: 75%; }
  .ds-col-md-10 { width: 83.3333%; }
  .ds-col-md-11 { width: 91.6667%; }
  .ds-col-md-12 { width: 100%; }
}

/* --- lg breakpoint (≥992px) --- */
@media (min-width: 992px) {
  .ds-col-lg-1  { width:  8.3333%; }
  .ds-col-lg-2  { width: 16.6667%; }
  .ds-col-lg-3  { width: 25%; }
  .ds-col-lg-4  { width: 33.3333%; }
  .ds-col-lg-5  { width: 41.6667%; }
  .ds-col-lg-6  { width: 50%; }
  .ds-col-lg-7  { width: 58.3333%; }
  .ds-col-lg-8  { width: 66.6667%; }
  .ds-col-lg-9  { width: 75%; }
  .ds-col-lg-10 { width: 83.3333%; }
  .ds-col-lg-11 { width: 91.6667%; }
  .ds-col-lg-12 { width: 100%; }
}

/* --- xl breakpoint (≥1200px) --- */
@media (min-width: 1200px) {
  .ds-col-xl-1  { width:  8.3333%; }
  .ds-col-xl-2  { width: 16.6667%; }
  .ds-col-xl-3  { width: 25%; }
  .ds-col-xl-4  { width: 33.3333%; }
  .ds-col-xl-5  { width: 41.6667%; }
  .ds-col-xl-6  { width: 50%; }
  .ds-col-xl-7  { width: 58.3333%; }
  .ds-col-xl-8  { width: 66.6667%; }
  .ds-col-xl-9  { width: 75%; }
  .ds-col-xl-10 { width: 83.3333%; }
  .ds-col-xl-11 { width: 91.6667%; }
  .ds-col-xl-12 { width: 100%; }
}

/* --- Gutter utilities (mirrors Bootstrap g-0..g-5) --- */
/* g-0: no gutters */
.ds-row.ds-g-0 { margin-inline: 0; }
.ds-row.ds-g-0 > * { padding-inline: 0; }

/* g-1: 0.25rem each side → 0.5rem column gap */
.ds-row.ds-g-1 { margin-inline: -0.125rem; row-gap: 0.25rem; }
.ds-row.ds-g-1 > * { padding-inline: 0.125rem; }

/* g-2: 0.25rem each side → 0.5rem column gap (Bootstrap step) */
.ds-row.ds-g-2 { margin-inline: -0.25rem; row-gap: 0.5rem; }
.ds-row.ds-g-2 > * { padding-inline: 0.25rem; }

/* g-3: 0.5rem each side → 1rem column gap */
.ds-row.ds-g-3 { margin-inline: -0.5rem; row-gap: 1rem; }
.ds-row.ds-g-3 > * { padding-inline: 0.5rem; }

/* g-4: 0.75rem each side → 1.5rem column gap */
.ds-row.ds-g-4 { margin-inline: -0.75rem; row-gap: 1.5rem; }
.ds-row.ds-g-4 > * { padding-inline: 0.75rem; }

/* g-5: 1.5rem each side → 3rem column gap */
.ds-row.ds-g-5 { margin-inline: -1.5rem; row-gap: 3rem; }
.ds-row.ds-g-5 > * { padding-inline: 1.5rem; }

/* =====================================================
   Button contract — [data-btn] (moved here in P1.1)

   Canonical, shared by BOTH rendering stacks. Previously lived in
   frontend/src/styles/tokens.css, which ships inside the island
   bundle — so server-rendered markup could not use it, and in dev
   mode django-vite injects that CSS via the HMR client, meaning any
   server-rendered button styled from there would render unstyled
   until JS executed. components.css ships as a plain <link> on every
   page (base.html:51), so it is the correct owner.

   Server templates emit this contract via templates/includes/_button.html;
   islands emit it via frontend/src/components/ui/Button.tsx. The legacy
   .ds-btn class system above is FROZEN — no new variants, no new call
   sites — and is being drained per page family. scripts/
   check_component_ownership.py ratchets it down and forbids redefining
   any of this in the bundle.
   ===================================================== */
/* ── Button system ([data-btn]) ──────────────────────────────────────────── */
[data-btn] {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-weight: 600;
  border-radius: var(--radius-full);
  border: 1px solid transparent;
  line-height: 1;
  cursor: pointer;
  text-decoration: none;
  white-space: nowrap;
  user-select: none;
  transition: background 150ms ease, color 150ms ease, border-color 150ms ease,
              box-shadow 150ms ease, filter 150ms ease, transform 150ms ease,
              opacity 150ms ease;
}

[data-btn]:focus-visible {
  outline: 2px solid var(--focus-ring, #1E8F4E);
  outline-offset: var(--focus-ring-offset, 2px);
}

[data-btn]:not(:disabled):not([data-loading]):active {
  transform: scale(0.97);
}

/* aria-disabled covers disabled LINKS. An <a> cannot carry a real `disabled`
   attribute — browsers ignore it and :disabled never matches — so a disabled
   navigation action would otherwise stay fully styled and clickable. Both
   templates/includes/_button.html and Button.tsx with asChild can produce
   this case. */
[data-btn]:disabled,
[data-btn][aria-disabled="true"],
[data-btn][data-loading] {
  opacity: 0.5;
  pointer-events: none;
  cursor: not-allowed;
}

[data-btn][data-full-width] { width: 100%; }

/* sizes */
[data-btn][data-size="sm"]   { min-height: 36px; padding: 0 12px; gap: 6px;  font-size: 13px; }
/* Raw `data-btn` markup (islands that don't go through <Button>) omits
   data-size — without a size rule those buttons get no padding/min-height at
   all and collapse to the line box. Default them to the md size. */
[data-btn][data-size="md"],
[data-btn]:not([data-size])  { min-height: 44px; padding: 0 20px; gap: 8px;  font-size: 14px; }
[data-btn][data-size="lg"]   { min-height: 48px; padding: 0 28px; gap: 10px; font-size: 16px; }
[data-btn][data-size="icon"] { width: 44px; height: 44px; padding: 0; font-size: 14px; }

/* Mobile touch-target floor: `sm` is a desktop density, not a phone one.
   min-height ALONE does not make a target 44x44 - it makes it 44 tall and as
   wide as the label. `sm` has 12px of side padding, so at 375px a two-letter
   label measured 39px wide ("Hi") and a one-character one 33px ("+"), against
   a floor that is 44 in BOTH directions (WCAG 2.5.5 / 2.5.8). min-width fixes
   the axis the floor was silently missing; the flex centring already on
   [data-btn] keeps the label centred in the wider box. */
@media (max-width: 639px) {
  [data-btn][data-size="sm"] { min-height: 44px; min-width: 44px; }
}

/* variants */
[data-btn][data-variant="primary"] {
  background: var(--craft-indigo);
  /* Ink fallback, not white: Electric Green is a LIGHT accent, so white lands at
     ~1.6:1. themes.css flips this to white for the dark indigo/purple palettes. */
  color: var(--accent-contrast, #0E2233);
  box-shadow: var(--shadow-tinted-sm);
}
[data-btn][data-variant="primary"]:not(:disabled):hover {
  background: var(--craft-indigo-hover);
  box-shadow: var(--shadow-tinted-md);
}

/* Secondary is the NEUTRAL treatment, matching .ds-btn--secondary (P1.2).
   Decided deliberately: .ds-btn--secondary is the single most-used variant
   in the codebase (139 of the 749 server call sites) and renders as a
   neutral sand chip. The bundle's version rendered as a green-outlined
   button instead. Converging on the neutral treatment means the eventual
   migration of those 749 call sites changes radius, height and focus ring
   but NOT colour on the most common variant — which is what makes that
   migration reviewable. It also reads as genuinely secondary next to the
   Electric Green primary, where a green outline competed with it.
   Callers that specifically want the emphasised outlined look should use
   variant="outline", which keeps that treatment. */
[data-btn][data-variant="secondary"] {
  background: var(--surface-light);
  border-color: var(--border-medium);
  color: var(--text-primary);
}
[data-btn][data-variant="secondary"]:not(:disabled):hover {
  background: var(--surface-highlight);
  border-color: var(--border-light);
}

[data-btn][data-variant="ghost"] {
  background: transparent;
  color: var(--craft-green-text);
}
[data-btn][data-variant="ghost"]:not(:disabled):hover { background: var(--craft-indigo-tint); }

[data-btn][data-variant="outline"] {
  background: transparent;
  border: 2px solid var(--craft-indigo);
  color: var(--craft-green-text);
}
[data-btn][data-variant="outline"]:not(:disabled):hover { background: var(--craft-indigo-tint); }

[data-btn][data-variant="danger"] {
  background: var(--craft-danger);
  color: #fff;
  box-shadow: 0 2px 8px rgba(239, 68, 68, 0.2);
}
[data-btn][data-variant="danger"]:not(:disabled):hover {
  background: var(--craft-danger-hover);
  box-shadow: 0 4px 14px rgba(239, 68, 68, 0.28);
}

[data-btn][data-variant="gradient"] {
  background: var(--craft-gradient-hero);
  color: var(--accent-contrast, #0E2233);
  box-shadow: var(--shadow-tinted-md);
}
[data-btn][data-variant="gradient"]:not(:disabled):hover {
  filter: brightness(1.07);
  box-shadow: var(--shadow-tinted-lg);
}

[data-btn][data-variant="link"] {
  background: transparent;
  border: none;
  border-radius: 0;
  color: var(--craft-green-text);
  text-decoration: underline;
  min-height: unset;
  height: auto;
  padding: 0;
  font-size: inherit;
}
[data-btn][data-variant="link"]:not(:disabled):hover { color: var(--craft-indigo-hover); }

/* spinner */
[data-btn-spinner] {
  width: 14px;
  height: 14px;
  border-radius: 9999px;
  border: 2px solid currentColor;
  border-top-color: transparent;
  display: inline-block;
  animation: btn-spin 0.8s linear infinite;
  flex-shrink: 0;
}

@keyframes btn-spin {
  from { transform: rotate(0deg); }
  to   { transform: rotate(360deg); }
}

@media (prefers-reduced-motion: reduce) {
  [data-btn-spinner] { animation: none; opacity: 0.6; }
}

/* =====================================================
   Disclosure — .ds-disclosure (P2.1)

   Server-side twin of the React SectionCard
   (frontend/src/components/ui/SectionCard.tsx), built on a native
   <details>/<summary> so it needs no JavaScript at all. Keyboard toggle,
   focus, and the open/closed state come free from the element.

   Matches SectionCard's visual language deliberately: a tinted header band
   (--domain-tint, falling back to --surface-light) carrying the title, an
   optional count badge, and a chevron in a bordered circle, over a neutral
   body. Reads the same --domain-* custom properties from any ancestor, so
   [data-domain="activities"] tints a server section exactly as it tints an
   island one.

   Before this existed, four templates hand-rolled <details> with their own
   inline <style> blocks and there was no bare details/summary rule anywhere
   in static/.

   Markup contract — see templates/includes/_disclosure_summary.html:
     <details class="ds-disclosure" open>
       <summary class="ds-disclosure__summary">
         <span class="ds-disclosure__title">...</span>
         <span class="ds-disclosure__meta">
           <span class="ds-badge ...">3</span>
           <span class="ds-disclosure__indicator" aria-hidden="true">svg</span>
         </span>
       </summary>
       <div class="ds-disclosure__body">...</div>
     </details>
   ===================================================== */
.ds-disclosure {
  background: var(--bg-card);
  border: 1px solid var(--border-medium);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-card);
  color: var(--text-primary);
  overflow: hidden;
}

.ds-disclosure + .ds-disclosure {
  margin-top: var(--space-md);
}

.ds-disclosure__summary {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-sm);
  /* 3.5rem matches SectionCard's min-h-[3.5rem] and clears the 44px
     touch-target floor with room to spare. */
  min-height: 3.5rem;
  padding: var(--space-sm) var(--space-md);
  background: var(--domain-tint, var(--surface-light));
  border-bottom: 1px solid transparent;
  cursor: pointer;
  list-style: none;
  transition: background var(--transition-fast);
}

/* Hide the native disclosure triangle in every engine; the chevron below
   replaces it. */
.ds-disclosure__summary::-webkit-details-marker {
  display: none;
}

.ds-disclosure__summary::marker {
  content: "";
}

.ds-disclosure__summary:hover {
  background: var(--domain-tint-strong, var(--bg-card-hover));
}

.ds-disclosure__summary:focus-visible {
  outline: 2px solid var(--focus-ring, #1E8F4E);
  outline-offset: -3px;
}

.ds-disclosure[open] > .ds-disclosure__summary {
  border-bottom-color: var(--border-light);
}

.ds-disclosure__title {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: var(--font-size-lg);
  line-height: 1.2;
}

.ds-disclosure__meta {
  display: inline-flex;
  flex: none;
  align-items: center;
  gap: var(--space-sm);
}

.ds-disclosure__indicator {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.75rem;
  height: 1.75rem;
  border: 1px solid var(--border-medium);
  border-radius: var(--radius-full);
  color: var(--domain-accent, var(--craft-indigo));
  transition: transform var(--transition-fast);
}

.ds-disclosure[open] > .ds-disclosure__summary .ds-disclosure__indicator {
  transform: rotate(180deg);
}

.ds-disclosure__body {
  padding: var(--space-md);
  color: var(--text-secondary);
  font-size: var(--font-size-base);
}

@media (prefers-reduced-motion: reduce) {
  .ds-disclosure__summary,
  .ds-disclosure__indicator {
    transition: none;
  }
}

/* =====================================================
   Activity action surfaces (P3, revised)

   Two separate pieces, by product decision (2026-08-13):

   .ds-activity-actions  static row of secondary + overflow actions, sitting
                         top-right beside the page title. Never moves.
   .ds-activity-primary  the ONE primary action. Sticks to the top of the
                         viewport once the title scrolls away, so the main
                         call to action is always reachable.

   This replaced a fixed bottom bar. Notes worth keeping, because both
   mistakes are easy to repeat:

   - position:sticky with `bottom` does NOT pin an element near the end of a
     page. A bottom-sticky element only floats while its containing block
     still has content below it, so it renders exactly where it sits in flow.
     Sticky with `top` on an element early in a tall column is the opposite
     case and works, which is why the primary sticks and the old bar did not.
   - A sticky element's travel is bounded by its PARENT's box, so
     .ds-activity-primary has to be a direct child of the tall content column.
     Nesting it inside a short header row silently reduces its range to zero.
   ===================================================== */
.ds-activity-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: flex-end;
  gap: var(--space-sm);
  margin-bottom: var(--space-sm);
}

.ds-activity-primary {
  position: sticky;
  /* Clear the sticky .top-bar (theme.css:943, z-index 900). Sits below it, so
     a lower z-index is correct - it must never cover the navigation. */
  top: calc(var(--topbar-min-height, 72px) + var(--space-sm));
  z-index: 800;
  /* Shrink-wrapped and right-aligned rather than a full-width block. A
     full-width sticky wrapper would travel down the page as an invisible
     sheet over the content behind it and swallow clicks on things like the
     poll vote button; sized to its content, it can only ever cover its own
     footprint. */
  width: max-content;
  max-width: 100%;
  margin-left: auto;
  margin-bottom: var(--space-md);
  /* Holds up to three [data-btn]s (Join/Cancel, Share Activity, Rally) since
     they were grouped in here alongside the primary action; each [data-btn]
     is inline-flex with no margin of its own, so without an explicit row
     layout they render glued edge to edge. */
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-sm);
}

/* The primary slot renders empty for the activity's creator and for a locked
   non-viewer - both are legitimate branches with no primary action - so it
   must collapse rather than leave a sticky gap under the actions row. */
.ds-activity-primary:empty {
  display: none;
}

/* The button keeps its own surface so it stays legible over whatever it
   passes while floating; flush against the title it reads as part of the
   header. */
.ds-activity-primary [data-btn] {
  box-shadow: var(--shadow-md);
}

/* Narrow viewports: a shrink-wrapped pill floating over a single-column
   layout lands directly on top of content. Measured at 390px it covered a
   poll option's "1 vote" badge 100% - the reader cannot tell the badge is
   there, which is worse than the button being less prominent. At >= 640px
   the 900px column leaves a ~173px gutter and the pill covers nothing
   (measured at 1440), so this only applies below that.
   Full width, no shared card surface: an opaque bar here used to sit on
   --bg-elevated, the same token .ds-action-bar__menu opens on. With the
   overflow menu opening directly below this row (see .ds-action-bar__more
   above) the two surfaces read as one merged shape with no visible seam.
   Each [data-btn] keeps its own shadow instead, so content still can't
   vanish underneath, but there's no bar-level card for the menu to fuse
   into. */
@media (max-width: 639px) {
  .ds-activity-primary {
    width: auto;
    margin-left: 0;
    display: flex;
    justify-content: flex-end;
  }
}

/* =====================================================
   Action bar — .ds-action-bar (P2.4)

   A plain, in-flow row of actions: one primary, up to three inline
   secondaries, the rest behind an overflow menu. Consumed by
   frontend/src/components/ui/ActionBar.tsx.

   Deliberately NOT positioned. An earlier revision pinned it to the bottom
   of the viewport; the product decision moved activity actions to the top of
   the page instead (see .ds-activity-primary above), and leaving a competing
   fixed-bottom behaviour here would have let the React and Django surfaces
   drift apart again - which is the whole failure this phase exists to fix.
   ===================================================== */
.ds-action-bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: flex-end;
  gap: var(--space-sm);
}

/* Overflow menu, server flavour. The React bar opens a Radix dropdown; a
   Django page has no React, so the same slot is a native <details> and needs
   no JavaScript. Both render .ds-action-bar__menu / __menu-item, so the menu
   surface and item styling above are shared. */
.ds-action-bar__more {
  position: relative;
}

.ds-action-bar__more > summary {
  list-style: none;
  cursor: pointer;
}

.ds-action-bar__more > summary::-webkit-details-marker {
  display: none;
}

.ds-action-bar__more > summary::marker {
  content: "";
}

/* Opens downward and right-aligned. This is the ONLY consumer of
   .ds-action-bar__more (the server-rendered <details> overflow menu on the
   activity detail page); the bar it belongs to sits in flow right under the
   page title, not bottom-sticky like the React ActionBar's Radix menu that
   the shared .ds-action-bar__menu / __menu-item styling also serves — so
   unlike that one, opening upward here pushed items off the top of the
   viewport (some of the menu's own entries were never visible). */
.ds-action-bar__more > .ds-action-bar__menu {
  position: absolute;
  right: 0;
  top: calc(100% + var(--space-xs));
  display: none;
}

.ds-action-bar__more[open] > .ds-action-bar__menu {
  display: block;
}

/* Server menu entries are <a> and <span>, not only <button> as in Radix. */
a.ds-action-bar__menu-item {
  text-decoration: none;
}

.ds-action-bar__menu-item[data-disabled] {
  pointer-events: none;
}

.ds-action-bar__menu {
  min-width: 180px;
  padding: 6px;
  background: var(--bg-elevated);
  color: var(--text-primary);
  border: 1px solid var(--border-medium);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-xl);
  z-index: 1300;
}

.ds-action-bar__menu-item {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  /* Touch-target floor, same as the button contract. */
  min-height: 44px;
  padding: 8px 10px;
  color: var(--text-secondary);
  border: 0;
  border-radius: var(--radius-sm);
  background: transparent;
  font: inherit;
  font-size: var(--font-size-sm);
  text-align: left;
  cursor: pointer;
}

/* Radix drives keyboard navigation through [data-highlighted], so that state
   IS the focus indicator and has to be visible on its own. --surface-light
   against the --bg-elevated menu is only 1.22:1 in both themes (in dark it is
   actually DARKER than the menu), and the text shift alone is imperceptible,
   which fails SC 2.4.7 / 2.4.11. --bg-card-hover carries a real delta, and an
   explicit ring is kept for non-Radix consumers. `outline: none` was removed
   rather than overridden. */
.ds-action-bar__menu-item:hover,
.ds-action-bar__menu-item[data-highlighted] {
  color: var(--text-primary);
  background: var(--bg-card-hover);
  box-shadow: inset 2px 0 0 var(--focus-ring, #1E8F4E);
}

.ds-action-bar__menu-item:focus-visible {
  outline: 2px solid var(--focus-ring, #1E8F4E);
  outline-offset: -2px;
}

.ds-action-bar__menu-item[data-disabled] {
  opacity: 0.5;
  cursor: not-allowed;
}

/* =====================================================
   Activity summary — .ds-activity-summary (B4)

   ONE contract at five form factors, per DESIGN.md section 5: card, compact
   row, dashboard row, calendar item, gallery detail. Emitted by
   templates/includes/_activity_summary.html; the island twin is
   frontend/src/components/ActivityCard.tsx.

   Everything shared lives on the base block and each factor changes only its
   LAYOUT — media size, direction, density. No factor gets its own colours,
   its own type scale, or its own border treatment, because the moment a
   factor owns a visual decision the five surfaces start drifting apart, and
   drifting apart is the exact failure this contract exists to stop.

   Not a .ds-card: the summary carries its own primary action, and a
   .ds-card with href would make the whole surface one click target with a
   button nested inside it — the case _card.html's rule forbids. The title
   is the link instead. The calendar factor IS a whole-element link and
   therefore renders no button; see the partial for that reasoning.
   ===================================================== */
.ds-activity-summary {
  display: flex;
  gap: var(--space-md);
  background: var(--bg-card);
  border: 1px solid var(--border-light);
  border-radius: var(--radius-lg);
  color: var(--text-primary);
}

.ds-activity-summary__body {
  display: flex;
  flex-direction: column;
  gap: var(--space-xs);
  /* min-width:0 or a long unbroken title (a URL, a German compound) forces
     the flex item wider than the row and pushes the action off the edge. */
  min-width: 0;
  flex: 1 1 auto;
}

.ds-activity-summary__title {
  margin: 0;
  font-size: var(--font-size-base);
  font-weight: 600;
  line-height: 1.3;
}

.ds-activity-summary__title a {
  color: inherit;
  text-decoration: none;
}

.ds-activity-summary__title a:hover {
  text-decoration: underline;
}

.ds-activity-summary__title a:focus-visible {
  outline: 2px solid var(--focus-ring, #1E8F4E);
  outline-offset: var(--focus-ring-offset, 2px);
  border-radius: var(--radius-xs);
}

/* Meta, organiser and status are all "icon + short text" runs. They wrap as
   whole items rather than splitting an icon from its label. */
.ds-activity-summary__meta,
.ds-activity-summary__organiser,
.ds-activity-summary__status {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-xs) var(--space-sm);
  margin: 0;
  font-size: var(--font-size-sm);
  color: var(--text-secondary);
}

.ds-activity-summary__meta-item,
.ds-activity-summary__capacity,
.ds-activity-summary__organiser {
  display: inline-flex;
  align-items: center;
  gap: var(--space-xs);
  min-width: 0;
}

.ds-activity-summary__meta-item .icon,
.ds-activity-summary__capacity .icon,
.ds-activity-summary__organiser .icon,
.ds-activity-summary__category .icon {
  /* Muted, not the text colour: these repeat what the label already says, so
     they must not compete with it for attention. They are aria-hidden in the
     partial, so nothing depends on them being read. */
  color: var(--text-muted);
  flex-shrink: 0;
}

.ds-activity-summary__organiser a {
  color: inherit;
}

.ds-activity-summary__category {
  display: inline-flex;
  align-items: center;
  align-self: flex-start;
  gap: var(--space-xs);
  font-size: var(--font-size-xs);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--text-muted);
}

/* Media slot. Fixed box with an object-fit image, so a portrait upload and a
   landscape one produce the same row height — the fallback exists for the
   same reason, and both are why the slot never collapses. */
.ds-activity-summary__media {
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  flex-shrink: 0;
  background: var(--surface-light);
  border-radius: var(--radius-md);
}

.ds-activity-summary__media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
}

.ds-activity-summary__media-fallback {
  display: inline-flex;
  font-size: var(--font-size-2xl);
  color: var(--text-muted);
}

.ds-activity-summary__action {
  display: flex;
  align-items: center;
  flex-shrink: 0;
}

/* ── Factor: card ────────────────────────────────────────────────────────
   Feed / grid tile. Vertical, media on top, action on its own row. */
.ds-activity-summary--card {
  flex-direction: column;
  gap: var(--space-sm);
  padding: var(--space-md);
  box-shadow: var(--shadow-card);
}

.ds-activity-summary--card .ds-activity-summary__media {
  width: 100%;
  height: 160px;
}

.ds-activity-summary--card .ds-activity-summary__title {
  font-size: var(--font-size-lg);
}

.ds-activity-summary--card .ds-activity-summary__action {
  justify-content: flex-end;
}

/* ── Factor: row ─────────────────────────────────────────────────────────
   Search results and browse lists. Horizontal, thumbnail, action at the end. */
.ds-activity-summary--row {
  align-items: center;
  padding: var(--space-sm) var(--space-md);
}

.ds-activity-summary--row .ds-activity-summary__media {
  width: 64px;
  height: 64px;
}

/* ── Factor: dashboard ───────────────────────────────────────────────────
   Stacked rows inside a narrow widget column. No media (see the partial for
   why), and no surface of its own: it sits inside a card that already has
   one, and a border on both reads as a box in a box. */
.ds-activity-summary--dashboard {
  align-items: center;
  padding: var(--space-sm) 0;
  background: none;
  border: 0;
  border-radius: 0;
}

.ds-activity-summary--dashboard + .ds-activity-summary--dashboard {
  border-top: 1px solid var(--border-light);
}

.ds-activity-summary--dashboard .ds-activity-summary__meta,
.ds-activity-summary--dashboard .ds-activity-summary__organiser,
.ds-activity-summary--dashboard .ds-activity-summary__status {
  font-size: var(--font-size-xs);
}

/* ── Factor: calendar ────────────────────────────────────────────────────
   One item in a day or week grid. The whole item is the link, so it needs
   the focus ring and hover state a nested title link would have carried. */
.ds-activity-summary--calendar {
  flex-direction: column;
  gap: 2px;
  padding: var(--space-xs) var(--space-sm);
  border-radius: var(--radius-md);
  font-size: var(--font-size-xs);
  text-decoration: none;
  /* The grid cell owns the width; the item must never widen it. */
  max-width: 100%;
  overflow: hidden;
}

.ds-activity-summary--calendar:hover {
  background: var(--bg-card-hover);
}

.ds-activity-summary--calendar:focus-visible {
  outline: 2px solid var(--focus-ring, #1E8F4E);
  outline-offset: var(--focus-ring-offset, 2px);
}

.ds-activity-summary--calendar .ds-activity-summary__title {
  font-size: var(--font-size-xs);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.ds-activity-summary--calendar .ds-activity-summary__meta,
.ds-activity-summary--calendar .ds-activity-summary__status {
  font-size: var(--font-size-xs);
  gap: 2px var(--space-xs);
}

/* ── Factor: detail ──────────────────────────────────────────────────────
   The gallery / activity-detail header. Same fields, biggest media. */
.ds-activity-summary--detail {
  flex-direction: column;
  gap: var(--space-md);
  padding: var(--space-lg);
  box-shadow: var(--shadow-card);
}

.ds-activity-summary--detail .ds-activity-summary__media {
  width: 100%;
  height: 320px;
  border-radius: var(--radius-lg);
}

.ds-activity-summary--detail .ds-activity-summary__title {
  font-size: var(--font-size-2xl);
}

.ds-activity-summary--detail .ds-activity-summary__meta,
.ds-activity-summary--detail .ds-activity-summary__organiser,
.ds-activity-summary--detail .ds-activity-summary__status {
  font-size: var(--font-size-base);
}

/* Narrow viewports: the horizontal factors stop being horizontal. A 64px
   thumbnail plus a body plus a button on one 375px line leaves the title
   about 120px wide, which wraps to four lines and makes the row taller than
   the stacked version it was trying to be denser than. */
@media (max-width: 639px) {
  .ds-activity-summary--row {
    flex-wrap: wrap;
  }

  .ds-activity-summary--row .ds-activity-summary__action,
  .ds-activity-summary--dashboard .ds-activity-summary__action {
    width: 100%;
    justify-content: flex-end;
  }

  .ds-activity-summary--detail {
    padding: var(--space-md);
  }

  .ds-activity-summary--detail .ds-activity-summary__media {
    height: 200px;
  }
}

/* =====================================================
   Person / member — .ds-person (B5)

   The person contract from DESIGN.md section 5, emitted by
   templates/includes/_person.html: avatar with fallback, display name,
   relationship or role, profile link, contextual action. Shared by
   communities, attendees, messages and profile lists.

   The avatar is .ds-avatar, not a .ds-person__avatar. A second
   circle-with-initials implementation is exactly how two surfaces end up
   with two different fallback colours for the same missing picture.

   The NAME is the link, never the row. The row carries an action of its
   own, and the whole-element-click-target rule documented on .ds-card
   applies here for the same reason.
   ===================================================== */
.ds-person {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  min-width: 0;
  color: var(--text-primary);
}

.ds-person__body {
  display: flex;
  flex-direction: column;
  /* min-width:0 so a long display name ellipsises instead of pushing the
     contextual action off the end of a narrow row. */
  min-width: 0;
  flex: 1 1 auto;
}

.ds-person__name {
  font-size: var(--font-size-base);
  font-weight: 600;
  line-height: 1.3;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.ds-person__name a {
  color: inherit;
  text-decoration: none;
}

.ds-person__name a:hover {
  text-decoration: underline;
}

.ds-person__name a:focus-visible {
  outline: 2px solid var(--focus-ring, #1E8F4E);
  outline-offset: var(--focus-ring-offset, 2px);
  border-radius: var(--radius-xs);
}

/* Role is --text-secondary rather than a badge: it is a relationship, not a
   status, and giving every member row a coloured pill turns a list of people
   into a list of labels. */
.ds-person__role {
  font-size: var(--font-size-sm);
  color: var(--text-secondary);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.ds-person__action {
  display: flex;
  align-items: center;
  flex-shrink: 0;
  margin-left: auto;
}

.ds-person--sm .ds-person__name {
  font-size: var(--font-size-sm);
}

.ds-person--sm .ds-person__role {
  font-size: var(--font-size-xs);
}

.ds-person--lg {
  gap: var(--space-md);
}

.ds-person--lg .ds-person__name {
  font-size: var(--font-size-lg);
}

/* =====================================================
   Tab variants — .ds-tabs--contained / --pill (C3 follow-on)

   frontend/src/components/ui/Tabs.tsx (branch w46/e-islands) emits
   `ds-tabs--contained` / `ds-tab--contained` and `ds-tabs--pill` /
   `ds-tab--pill`, and no CSS existed for either name, so both variants
   rendered as the plain underline row - a variant that silently does
   nothing. components.css is the canonical owner of the .ds-tab vocabulary
   (the ownership gate forbids defining it in the bundle), so the rules
   belong here rather than beside the component.

   Both variants have to UNDO the underline treatment the base block carries:
   .ds-tabs owns a 2px bottom border and .ds-tab hangs a transparent 2px
   border into it with a -2px margin. Left in place, a pill row shows a stray
   rule under the chips and each chip sits 2px low.

   Written to sit AFTER the base active-state rule on purpose: `.ds-tab--pill
   .is-active` and `.ds-tab.is-active` have identical specificity, so source
   order is what decides, and a variant defined earlier in the file would
   lose to the underline it is trying to replace.
   ===================================================== */
.ds-tabs--contained,
.ds-tabs--pill {
  border-bottom: 0;
  gap: var(--space-xs);
}

.ds-tab--contained,
.ds-tab--pill {
  border-bottom: 0;
  margin-bottom: 0;
  border-radius: var(--radius-full);
}

/* Contained: a segmented control. The track carries the surface, the active
   segment lifts out of it. */
.ds-tabs--contained {
  padding: var(--space-xs);
  background: var(--surface-light);
  border-radius: var(--radius-full);
  /* The track is shrink-wrapped; a full-bleed pill track reads as a search
     field, not as a choice between two or three things. */
  width: max-content;
  max-width: 100%;
}

.ds-tab--contained.is-active,
.ds-tab--contained[aria-selected="true"] {
  background: var(--bg-elevated);
  color: var(--text-primary);
  box-shadow: var(--shadow-sm);
}

/* Pill: free-standing chips, no track. The active chip is filled. */
.ds-tab--pill {
  border: 1px solid var(--border-medium);
}

.ds-tab--pill:hover {
  background: var(--craft-indigo-tint);
}

.ds-tab--pill.is-active,
.ds-tab--pill[aria-selected="true"] {
  background: var(--craft-indigo);
  border-color: var(--craft-indigo);
  /* Ink fallback, not white, for the same reason the primary button uses it:
     Electric Green is a light accent and white lands near 1.6:1 on it.
     themes.css flips --accent-contrast for the dark palettes. */
  color: var(--accent-contrast, #0E2233);
}

/* Selection is never carried by colour alone (DESIGN.md non-negotiable), and
   for these two variants the underline that used to carry it is gone. Weight
   is the second cue, inherited from the base active rule; aria-selected is
   the third and is what assistive tech actually reads. */
.ds-tab--contained.is-active,
.ds-tab--contained[aria-selected="true"],
.ds-tab--pill.is-active,
.ds-tab--pill[aria-selected="true"] {
  font-weight: 600;
}

/* ── Password requirement checklist ──────────────────────────────────────
   Rendered by the signup island (frontend/src/components/SchemaForm.tsx) from
   the server's own policy (core.password_validators.password_rules). Defined
   here because static/core owns the shared .ds-* vocabulary; the ownership
   gate blocks the bundle from redefining it.
   The mark is a CSS-drawn dot/check rather than a glyph so it carries no text
   for a screen reader to read out - the met/unmet state belongs to the
   aria-live list, not to a decorative character. */
.ds-password-rules {
  list-style: none;
  margin: var(--space-xs) 0 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
}

.ds-password-rules li {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  font-size: 0.85rem;
  color: var(--text-muted);
}

.ds-password-rules li[data-met="true"] {
  color: var(--craft-green-text, var(--text-secondary));
}

.ds-password-rules__mark {
  flex: 0 0 auto;
  width: 0.85rem;
  height: 0.85rem;
  border-radius: 50%;
  border: 1.5px solid currentColor;
  opacity: 0.55;
  position: relative;
}

.ds-password-rules li[data-met="true"] .ds-password-rules__mark {
  opacity: 1;
  background: currentColor;
}

/* The check, drawn inside the filled dot. */
.ds-password-rules li[data-met="true"] .ds-password-rules__mark::after {
  content: "";
  position: absolute;
  left: 0.24rem;
  top: 0.08rem;
  width: 0.2rem;
  height: 0.4rem;
  border: solid var(--bg-primary);
  border-width: 0 1.5px 1.5px 0;
  transform: rotate(45deg);
}
