---
name: teenyapp-settings-page
description: Build a settings or preferences page in a teenyapp project — schema in teenybase.ts, a Hono route that serves the page, and a Discord-grade token system for surfaces, controls, and rows. Use when the user asks for settings, preferences, account settings, a profile/config screen, a "my account" page, notification settings, or an admin-facing options panel in a teenyapp / teenybase project.
---

# Settings pages in teenyapp

teenyapp gives you a backend in one file. It does **not** prescribe frontend
conventions — `agents.md` is silent on CSS, components, and page structure. This
skill fills that gap for one screen type, so every settings page across teenyapp
projects comes out coherent instead of improvised.

The visual system below is derived from a live teardown of Discord's settings
client. Copy the *structure*, not the hues — the accent is a single token.

---

## Platform constraints (do not violate)

These come from teenyapp's own `agents.md`. Breaking them breaks the deploy.

- **One config file.** All schema lives in `teenybase.ts`. Schema changes are
  auto-diffed into migrations — **never write SQL migrations by hand.**
- **No external ORM.** No Drizzle, no Prisma. Use teenybase's native query layer.
- **ESM only**, Cloudflare Workers runtime. No Node built-ins, no `require`.
- **Server routes are Hono**, deployed in the same Worker as the API.
- Auto-generated REST lives at `/api/v1/table/{name}/{list|view|insert|update|delete}`.
- Access control is a **`rules` extension** on the table carrying `listRule` /
  `viewRule` / `createRule` / `updateRule` / `deleteRule` as JS-like expression
  strings — `auth.uid == user_id`, combined with `&` and `|`.
- The project auto-builds and redeploys on every file save; preview is at
  `https://{slug}.app.teenyapp.com`.

> Verified against a live teenyapp project. Per-record writes are
> `POST /api/v1/table/{name}/edit/{id}` with a `{ setValues: {...} }` body;
> `insert` takes `{ values: {...}, returning: [...] }`; `update` takes
> `{ where, setValues }`. Add `or: 'INSERT'` to `edit/:id` to upsert.
> Re-check `/api/v1/doc` if the project pins a different teenybase version.

**Live reference implementation:** <https://settings-design-kit.app.teenyapp.com>
— every rule below is applied there, and the source of this skill is served at
`/skill.md` on that app.

---

## Step 1 — Model settings as one row per user

A settings page is a single wide row keyed to the user, not a key/value bag. Wide
rows give you typed fields, defaults, and RLS in one place.

Follow the shape teenybase already uses for `posts`: spread `baseFields`, attach
`createdTrigger` / `updatedTrigger`, and declare access through the **`rules`
extension** (not a `rules` object).

```ts
// teenybase.ts — add to the tables array
{
  name: 'user_settings',
  autoSetUid: true,
  fields: [
    ...baseFields,
    tableField('user_id', 'relation', 'text', {
      notNull: true, foreignKey: { table: 'users', column: 'id' },
    }),

    // Appearance — enum values are documented here and the <select> must match
    tableField('theme',         'select',  'text',    { notNull: true, default: sql`'system'` }),      // system | light | dark
    tableField('density',       'select',  'text',    { notNull: true, default: sql`'comfortable'` }), // comfortable | compact
    tableField('reduce_motion', 'bool',    'boolean', { notNull: true, default: sql`0` }),

    // Notifications
    tableField('notify_email',  'bool',    'boolean', { notNull: true, default: sql`1` }),
    tableField('digest',        'select',  'text',    { notNull: true, default: sql`'weekly'` }),      // off | daily | weekly
    tableField('quiet_hours',   'integer', 'integer', { notNull: true, default: sql`0` }),

    // Profile
    tableField('display_name',  'text',    'text',    {}),
  ],
  indexes: [{ fields: 'user_id', unique: true }],   // one settings row per user
  extensions: [{
    name: 'rules',
    listRule:   'auth.uid != null & user_id == auth.uid',
    viewRule:   'auth.uid != null & user_id == auth.uid',
    createRule: 'auth.uid != null & user_id == auth.uid',
    updateRule: 'auth.uid != null & user_id == auth.uid',
    // deleteRule omitted = deny all. Settings rows are reset, never deleted.
  }],
  triggers: [createdTrigger, updatedTrigger],
}
```

Rules for this step:

- **Rule expressions use `&` for AND and `|` for OR** — not `&&` / `||`.
- **Let the triggers own timestamps.** `createdTrigger` / `updatedTrigger` already
  maintain them; do not hand-roll an `updated_at` field or set it from the client.
- **Every field gets a `default: sql`...``.** The UI must render correctly for
  a user whose settings row does not exist yet — defaults come from the schema,
  not from JS fallbacks scattered through the page.
- **Enum-ish fields are `text` with the value set in a trailing comment.** The
  select options in the UI must match that set exactly.
- **Omit `deleteRule`.** Rules are deny-by-default, so leaving it out is how a
  settings row becomes undeletable. Reset to defaults instead.
- **One row per user** — a `unique` index on `user_id`, verified working.
- **`type: 'file'`** needs an `r2Base` on the table. Skip avatars unless you
  actually want object storage wired up.
- If a setting affects how *other* users see this user (display name, avatar),
  keep it on the `users` table and read it here — do not duplicate the column.

---

## Step 2 — Drop in the token block

One `<style>` block, at the top of the page. **No hex value appears anywhere
below this block.** If you need a color in a component, you are missing a token.

```css
:root {
  /* Surface ladder — one hue, ~3–4% lightness apart. Depth is lightness, not shadow. */
  --surface-0:#131416;   /* inset wells, cards inside content   */
  --surface-1:#191a1d;   /* chrome: nav rail, page/modal shell  */
  --surface-2:#1f2023;   /* content pane — the brightest area   */
  --surface-3:#232427;   /* hovered tile                        */
  --surface-5:#2c2d31;   /* popovers, menus (the only shadowed) */

  /* One neutral, four alphas — every interaction state in the app */
  --mod:151 153 159;
  --mod-muted:rgb(var(--mod)/.08);    /* hover        */
  --mod-subtle:rgb(var(--mod)/.12);   /* tinted fill  */
  --mod-normal:rgb(var(--mod)/.16);   /* selected     */
  --mod-strong:rgb(var(--mod)/.20);   /* pressed, borders */

  /* Text — four steps, contrast-checked */
  --text-strong:#fbfbfb;
  --text-default:#efeff1;
  --text-subtle:#abacb2;
  --text-muted:#96979e;

  /* Intent — your brand lives here and nowhere else */
  --accent:#5865f2;
  --accent-hover:#4452bb;
  --accent-active:#3a48a3;
  --focus:#6aa8f4;        /* deliberately NOT --accent */
  --critical:#d22d39;
  --positive:#5eb479;
  --warning:#ea9800;

  /* Geometry */
  --r-control:8px;  --r-container:12px;
  --h-md:40px;      --h-sm:32px;
  --measure:696px;  --nav-item:220px;
}
```

Swapping the palette is a five-value edit: the seven surfaces, the `--mod`
neutral, the four text steps, `--accent*`, and `--focus`. Nothing else changes.

**Bias the neutral.** `--mod` should sit a few points toward your accent's hue.
A pure grey overlay on a tinted ground reads as dirt.

---

## Step 3 — Serve the page from a Hono route

Settings is authenticated and personal, so render the shell server-side and
hydrate the values client-side from the REST API. Keep it in the same Worker.

```ts
app.get('/settings', (c) => c.html(settingsShell()))
```

Register it wherever the project already mounts its Hono routes — teenybase's
README does not show a route example, so match the existing file rather than
inventing a new entry point. There is no bundler in the loop: **inline the CSS
and JS in the returned HTML.** Do not add a frontend build step.

Layout, non-negotiable:

| Region | Spec |
| --- | --- |
| Nav rail | `--surface-1`, items `--nav-item` wide, **36px** tall, `padding:8px`, `gap:12px`, `border-radius:var(--r-control)` |
| Content pane | `--surface-2` — **lighter than the chrome that frames it** |
| Content column | `max-width:var(--measure)`, 16px gutters, regardless of viewport |
| Sticky header | Current section name + close/back only. No actions. |
| Cards in content | `--surface-0` — inset **wells**, they go *darker*. Nothing floats. |
| Divider | `1px`, `40px` margin above and below, **only between major topics** |
| Row spacing | `16px` between rows, `8px` inside a row group |

Two levels of nav, maximum: top-level items get an icon and a filled selected
state (`--mod-normal`); sub-items get no icon and a 2px left bar.

---

## Step 4 — The row is the unit

Every setting is the same object: label, optional description, control. Two-column
grid, 24px gap, control right-aligned and vertically centered on the label.

```html
<div class="row">
  <div>
    <div class="row-label">Email notifications</div>
    <div class="row-desc">Send a message when someone replies to your thread.</div>
  </div>
  <button class="sw" role="switch" aria-checked="true" data-field="notify_email"></button>
</div>
```

```css
.row{
  display:grid; grid-template-columns:1fr auto; gap:24px; align-items:center;
  padding:8px 12px; margin:0 -12px;            /* highlight bleeds past the text column */
  border-radius:var(--r-container);
}
.row:hover{ background:var(--mod-muted); }
.row-label{ font:600 16px/20px var(--font); color:var(--text-default); }
.row-desc { font:400 14px/18px var(--font); color:var(--text-muted); max-width:52ch; margin-top:2px; }
```

The negative margin is the detail that makes it feel finished — the hover
highlight extends past the content edge instead of clipping to the label.

**Hierarchy comes from weight and color, not size.** The whole page lives in four
sizes:

- Page title — `24/30`, weight **400** (the biggest text is the lightest weight)
- Section heading — `20/24`, weight 600
- Row label — `16/20`, weight 600
- Description — `14/18`, weight 400, `--text-muted`
- Group eyebrow — `12/16`, weight 700, uppercase, `letter-spacing:.24px`

---

## Step 5 — The control kit

Two heights (`--h-md` 40px, `--h-sm` 32px), two radii (8 controls, 12 containers).

```css
/* Buttons — filled surfaces carry a 1px inner light stroke. This is what stops
   a saturated fill from looking pasted on. */
.btn{ height:var(--h-md); padding:0 16px; border-radius:var(--r-control);
      font:500 14px/1 var(--font); cursor:pointer; }
.btn-primary{ background:var(--accent); color:#fff; border:1px solid rgb(255 255 255/.08); }
.btn-primary:hover { background:var(--accent-hover); }
.btn-primary:active{ background:var(--accent-active); }
.btn-tinted { background:var(--mod-subtle); color:var(--text-default);
              border:1px solid rgb(255 255 255/.04); }
.btn-danger { background:var(--critical); color:#fff; border:1px solid rgb(255 255 255/.08); }

/* Fields go DOWN, buttons come UP. An input is a hole punched in whatever
   surface it lands on — which is why one rule works in a pane, a card, or a modal. */
.field{ height:var(--h-md); border-radius:var(--r-control); padding:0 12px;
        background:rgb(0 0 0/.12); border:1px solid var(--mod-strong);
        color:var(--text-default); }

/* Toggle — 48×24 pill. Off: translucent black + 20% border. On: accent + 12%. */
.sw{ width:48px; height:24px; border-radius:999px; position:relative;
     background:rgb(0 0 0/.12); border:1px solid var(--mod-strong); cursor:pointer; }
.sw::after{ content:""; position:absolute; inset:2px auto 2px 2px; width:18px;
            border-radius:50%; background:#fff; transition:transform .16s cubic-bezier(.2,.7,.3,1); }
.sw[aria-checked="true"]{ background:var(--accent); border-color:var(--mod-subtle); }
.sw[aria-checked="true"]::after{ transform:translateX(24px); }

/* Focus is its own color, everywhere, no exceptions. */
:focus-visible{ outline:2px solid var(--focus); outline-offset:2px; }
.row:focus-within{ outline-offset:-2px; }   /* full-bleed rows ring inside */

@media (prefers-reduced-motion:reduce){ *{ transition:none !important; } }
```

Radio: 20px circle, 12px gap, 42px row, **the whole row is the hit target**.
Slider: 4px rail, accent fill, white knob with a shadow, 24px hit area.

Pick the control by cardinality: 2 states → toggle · 3–5 → radio list ·
6+ → select · free text → field · irreversible → button in the `critical` family.

---

## Step 6 — Persist per control, not per page

No Save button for toggles and selects. Write on change, optimistically, and roll
back visibly if the write fails.

```js
async function saveSetting(field, value, el) {
  const prev = el.getAttribute('aria-checked') ?? el.value;
  applyOptimistic(el, value);                     // UI moves immediately
  try {
    const res = await fetch(`/api/v1/table/user_settings/edit/${settingsId}`, {
      method: 'POST',
      credentials: 'include',                      // cookie auth; or send a Bearer JWT
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ setValues: { [field]: value } }),
    });
    if (!res.ok) throw new Error(await res.text());
    flash(el, 'saved');
  } catch (err) {
    applyOptimistic(el, prev);                    // roll back
    toast(`Couldn't save ${label(field)}. Check your connection and try again.`);
  }
}
```

- **Row-level rules are the security boundary**, not the UI. `auth.uid == user_id`
  must be on the table; never rely on the page hiding a control.
- **First load with no row:** `insert` a row from schema defaults on first write,
  or seed it at signup. Never render an empty page.
- **Free-text fields** (display name) *do* get an explicit Save — debounced writes
  on every keystroke are wrong. Toggles and selects do not.
- **Destructive actions** (delete account, revoke sessions) require a confirm
  modal: `--surface-5`, `--r-container`, 1px stroke, `0 12px 24px rgb(0 0 0/.24)`,
  over a `rgb(0 0 0/.72)` scrim. The confirm button is the `critical` family.

---

## Verification

Before calling the page done:

- [ ] `grep -n '#[0-9a-fA-F]\{3,8\}' ` over the page CSS returns **only** the token
      block. Any other hit is a missing token.
- [ ] Every interactive element has a visible `:focus-visible` ring, and the ring
      is `--focus`, not `--accent`.
- [ ] Tab order walks the nav, then the rows, top to bottom. Toggles are real
      `role="switch"` with `aria-checked`, not styled divs.
- [ ] Turn the network off, flip a toggle: the control returns to its old state
      and a toast explains why.
- [ ] Hit `/api/v1/table/user_settings/insert` with no session at all. It must
      write nothing — confirm with an admin read that the table is unchanged.
- [ ] Log in as a second user and hit `/api/v1/table/user_settings/edit/{id}` with
      the first user's row id. It must fail on the rule, not on the UI.
- [ ] Content column is capped at `--measure` on a 2560px monitor.
- [ ] Every schema field with a default renders correctly for a brand-new user
      with no settings row.
- [ ] No hand-written SQL migration was added; the diff to `teenybase.ts` is the
      only schema change.

---

## Anti-patterns

| Don't | Do |
| --- | --- |
| A `settings` key/value table with a `value` text column | One wide typed row per user |
| Cards inside the content pane that get *lighter* | Inset wells that go darker |
| Shadows on in-page containers | Shadows only on modals, popovers, menus |
| A new grey for hover, another for selected | `--mod-muted` / `--mod-normal` |
| `--accent` as the focus ring | A separate `--focus` hue |
| One Save button at the bottom for 30 toggles | Write on change; Save only for text |
| Six font sizes competing | Four sizes; separate with weight and text color |
| Dividers between every heading | Dividers only between major topics, 40px |
