# Poll theming and custom branding: requirements, current approach, recommendation

Audience: the team building `frontend-v3`. No knowledge of the legacy Vue 2 code is assumed.

Status: proposal. Sections marked **Open decision** need an answer from the Kulibri side before implementation. The description of the current system was derived from reading the code, not from running it. Where production data matters, this is stated.

---

## 1. Requirements

### 1.1 Functional

1. **Dark / light appearance per poll.** The poll owner chooses whether the poll page (header, answer grid, comments, tasks) renders dark or light. Everyone who opens the poll link sees the owner's choice. It is not a per-visitor preference and does not follow the OS setting.
2. **Custom branding for premium users.** A premium poll owner can customise:
   - **Logo**: image (uploaded by the user), height, and a link opened on click.
   - **Header**: background colour, background opacity, font colour.
   - **Answer grid**: dark or light mode, and the opacity of the grid background for each mode.
3. **Named templates ("designs").** A user can save the current settings as a named design, switch between saved designs, update one and delete one. A design can be used by several polls.
4. **Free users** get the default look and the dark/light choice, but not the custom logo and header.
5. **Live preview.** While the owner edits (sliders, colour pickers, choosing a logo), the poll page changes immediately. Cancelling restores the saved design.
6. **Visitors see the result without editing.** Anonymous participants opening a poll link must see the owner's branding.

### 1.2 Non-functional

- Changes must apply **immediately and completely**, including elements created after the change (new participant rows, tab switches, mobile/desktop layout switch).
- Legacy and v3 frontends coexist on the same URLs and share the same API and data, so the design data format must stay compatible.
- Premium features must be enforced on the server, not only hidden in the UI (see 3.6).

---

## 2. Current approach (legacy frontend)

### 2.1 Data model

| Table / field | Meaning |
|---|---|
| `designs` | One row per design: `user_id`, `name`, `config` (JSON), `active`, `isSelected`, `isDefault` |
| `polls.design_id` | The design a poll uses (one design per poll) |
| `user_logos` | Logo files uploaded by a user; the chosen logo's URL is copied into the design config |

The `config` JSON (the contract the API returns and accepts):

```json
{
  "reminder": 2,
  "logoDetails":      { "logoImg": "/images/logo.png", "height": 20, "link": "https://app.kulibri.com/" },
  "headerSection":    { "backgroundColor": "#000000", "backgroundOpacity": 60, "fontColor": "#FFFFFF" },
  "answerGridSection":{ "selectedMode": "dark", "darkModeOpacity": 80, "lightModeOpacity": 80 }
}
```

Opacities are integers 0 to 100. The TypeScript types already exist in `frontend-v3/src/api/endpoints.ts` (`ApiDesignConfig`, `ApiDesign`).

A **default design** is a system row with `isDefault = 1`. Every new poll is assigned it. A migration guarantees one exists, with no owner (`user_id` null). Default designs are visible to everyone. A user's own designs are listed before them.

### 2.2 API

All ids are hashids on the wire.

| Call | Purpose |
|---|---|
| `GET /api/designs` | The user's active designs plus the default designs |
| `POST /api/designs` | Create ("save as"). Body: `name`, `config`, `poll_id`. Also assigns it to the poll and selects it |
| `PUT /api/designs/{id}` | Update config. Body: `config`, `name`, `poll_id`. Also assigns it to the poll |
| `DELETE /api/designs/{id}` | Delete. If it was selected, its polls fall back to the default design |
| `DELETE /api/designs/{pollId}/{id}` | Despite the verb, this does **not** delete. It returns `{count}`, the number of other polls using the design, so the UI can warn first |
| `POST /api/logo/upload` | Upload a logo (multipart `file`); returns the logo record |
| `DELETE /api/logo/delete/{id}` | Delete a logo |

The poll payload includes the resolved `design` object, which is how anonymous visitors get the branding.

### 2.3 Two separate flags for "dark"

Legacy stores dark mode in two places that are kept in sync by hand:

- `poll.options.darkMode` (boolean on the poll), used by components to choose CSS classes (`dark-mode`, `light-mode`, `dark-mode-bg`, `dark-mobile-mode`, and Firefox variants ending in `-ff`).
- `design.config.answerGridSection.selectedMode` (`dark` or `light`).

The quick "Dark mode" toggle in the appearance panel exists only for default designs. It works by finding the default design whose **name contains** "dark" or "light" and selecting it. This relies on production data (design names) that the seeder and migration in the repo do not create. **Which default designs actually exist in production has not been verified.**

### 2.4 How a design is applied: `frontend/src/shared/customTemplate.js`

`renderHTML(design)` is imperative DOM manipulation:

1. `document.getElementById('web-logo')`: sets `src` and `style.height`.
2. `document.querySelectorAll('.<mode>-mode.headline-bg')`: sets inline `backgroundColor` (rgba built from hex plus opacity).
3. `document.querySelectorAll('.k-navbar, .headline__middle.new-poll, …')`: sets inline `color` and `borderColor`.
4. Inside `setTimeout(…, 200)`: `querySelectorAll` over a long list of grid/legend/task class names and sets inline `rgba(0,0,0,opacity)` or `rgba(255,255,255,opacity)`.

It is called from `Renderer.vue` (when the poll loads), `RenderData.vue`, the branding panel (on every slider or colour change), and `Headline.vue` (when panels close).

---

## 3. Drawbacks

### 3.1 Changes are not applied to elements that appear later (the reported bug)

`querySelectorAll` runs once against the DOM as it is at that moment. It writes inline styles on the elements it finds. Vue then re-renders: rows are added, tabs are switched, the mobile layout replaces the desktop one, lists are re-sorted. New or re-created elements never receive the styles, and re-created ones lose them. The result is what has been observed: the branding looks right only after a full refresh, when everything is rendered before `renderHTML` runs.

### 3.2 Timing hacks

The `setTimeout(…, 200)` calls guess how long Vue needs to render. On a slow device or a large poll the guess is wrong, and the styling silently misses.

### 3.3 Coupling between JS and CSS class names

The script's selectors (`.view-content.dark-mode-bg-ff`, `.legend-content-mobile.light-mode-bg`, and so on) must match the templates and SCSS. Renaming a class in a component silently breaks branding with no error.

### 3.4 Several sources of truth

Dark mode lives in `poll.options.darkMode`, in the design's `selectedMode`, and in the DOM (inline styles). Every call site has to keep them in sync (`setDarkMode(...).then(() => renderHTML(...))`). Forgetting one gives a mixed state.

### 3.5 Fragile default-design lookup

Toggling dark/light for default designs depends on design **names** containing "dark" or "light".

### 3.6 Server-side authorization is missing (security)

Observed in `DesignController` and `routes/api.php`:

- The `/designs` routes sit in the `hashids` + `logger` group with **no** `auth:sanctum`.
- `update` and `destroy` do not check that the design belongs to the caller.
- There is no premium check. Premium is enforced only by the UI (`isPremium`), so the API can be called directly.
- `updateAllSelectedToDefault` contains a hardcoded user id (`2`).

This matters for the redesign because branding is a premium feature. It is a backend task, listed here so it is not forgotten.

---

## 4. Recommended approach

Principle: **the design object is the only state. The UI is a pure function of it.** No DOM queries and no timers.

### 4.1 One source of truth

- Keep the design `config` as the state. Derive `mode` (`dark` or `light`) from `config.answerGridSection.selectedMode`.
- v3 does **not** use `poll.options.darkMode` for rendering. If the API still needs the flag for legacy compatibility, the composable can write it when saving, but it is never read for display.

### 4.2 Turn the design into CSS variables on one element

A composable, for example `usePollTheme(design)`, exposes a computed style object and a mode. It is bound once on the poll page wrapper:

```vue
<div :data-theme="mode" :style="themeVars" class="poll-page">
  <PollHeader />   <!-- logo and header use var(--k-header-bg) etc. -->
  <AnswerGrid />   <!-- uses var(--k-grid-bg) -->
</div>
```

```ts
// usePollTheme.ts (sketch)
export function usePollTheme(design: Ref<ApiDesign | null>) {
  const cfg = computed(() => withDefaults(design.value?.config));
  const mode = computed(() => cfg.value.answerGridSection.selectedMode);
  const themeVars = computed(() => {
    const c = cfg.value;
    const gridOpacity = c.answerGridSection[mode.value + 'ModeOpacity'] / 100;
    return {
      '--k-header-bg': rgba(c.headerSection.backgroundColor, c.headerSection.backgroundOpacity / 100),
      '--k-header-fg': c.headerSection.fontColor,
      '--k-grid-bg': mode.value === 'dark' ? `rgba(0,0,0,${gridOpacity})` : `rgba(255,255,255,${gridOpacity})`,
      '--k-logo-height': `${c.logoDetails.height}px`,
    };
  });
  return { mode, themeVars, logo: computed(() => cfg.value.logoDetails) };
}
```

Components reference the variables through Tailwind arbitrary values (`bg-[var(--k-header-bg)]`) or through tokens in `tailwind.config.js` that map to the variables. When a slider changes the design ref, Vue updates the variables and the browser repaints every element that uses them, including ones created later. No re-run step exists, so the bug in 3.1 cannot occur.

### 4.3 Dark / light through `data-theme`

- Define the two palettes once, in CSS, keyed by `[data-theme="dark"]` and `[data-theme="light"]`, or use Tailwind `darkMode: ['selector', '[data-theme="dark"]']` and `dark:` classes.
- This is the same mechanism the new UI already uses (`data-theme="dark"`, `useTheme.ts`). One mechanism serves the poll page and the app shell.

### 4.4 The logo is a normal reactive binding

```vue
<a :href="logo.link" target="_blank" rel="noopener">
  <img :src="logo.logoImg" :style="{ height: 'var(--k-logo-height)' }" alt="" />
</a>
```

No `getElementById('web-logo')`.

### 4.5 Editing and preview

The branding panel edits a **draft copy** of the design (a local ref). The poll page binds to the draft while the panel is open, so every slider gives live preview by construction. Save sends the draft to the API and replaces the stored design. Cancel discards the draft. This removes the legacy `localStorage` juggling (`currentTemplate`) used to survive navigating to the logo picker. A shared store or the router state can keep the draft while the logo picker is open.

### 4.6 Keep the data contract

Keep the `config` JSON shape and the existing endpoints, so legacy and v3 keep working on the same polls while both are live. The CSS-variable layer sits on top and needs no schema change.

### 4.7 Robustness

- `withDefaults()` fills missing keys. Older or hand-created designs (some tests create designs with an empty `config`) must not crash the page.
- Validate hex colours before use. Legacy throws `Bad Hex` on invalid input.
- Optional: warn when header background and font colour have too little contrast.
- Only `http(s)` links for the logo link (legacy prepends `http://` if missing).

### 4.8 Testing

Because the theme is a computed value, it can be unit-tested with Vitest without a DOM: given a config, assert the variables and the mode. A component test can assert that changing the design changes the wrapper's `style` and `data-theme`, and that a row added after the change still inherits the variables.

---

## 5. Open decisions

1. **Scope of theming.** Recommendation: branding (custom logo, header colours, grid opacity) applies to the **poll page only**. The app shell (dashboard, settings) follows a separate user preference (OS or manual toggle). Legacy did not have this separation. Confirm.
2. **Default designs.** How should dark and light defaults be represented in v3? Options:
   a. Keep selecting a default design by name (needs production data verified, fragile).
   b. Treat dark/light on a default design as a change of `selectedMode` only, with the two palettes in CSS.
   Recommendation: (b), after checking which default rows exist in production.
3. **What free users may change.** Legacy allows free users to save only the dark/light choice and opacity, with logo and header taken from the default design. Confirm that this stays.
4. **Poll payload.** Confirm that `poll.design` continues to be returned so anonymous visitors do not need a separate `GET /api/designs`.
5. **Backend hardening (backend side, not the frontend).** Add `auth:sanctum`, an ownership check, and a premium check to the design endpoints; remove the hardcoded user id. This should ship with the redesign so premium branding cannot be bypassed.

---

## 6. Migration notes

- Legacy remains the fallback and keeps its own `customTemplate.js`. It is not being fixed, since the dependency on rendered DOM is inherent to that approach.
- v3 takes over the poll page route only after this behaviour is implemented, per the coexistence rules in `CLAUDE.md` (add the route and the `config/kulibri_ui.php` `v3_paths` entry together).
- Visual parity to check when comparing with legacy: header colour and opacity, grid opacity in both modes, logo size and link, mobile layout.
