Overhaul Oz Styling #75

Open
opened 2026-09-20 17:09:43 -07:00 by tepichord · 2 comments
Owner
  • Current styling on oz webpage leaves much to be desired.
  • The table has a weak presence
  • The buttons are flat and don't fit with the page
  • Animations are absent
  • Most actions take you to another page (at least in preview mode)
  • Preview mode is broken, with activate resulting in a 501 error rather than faking the actions
  • Grouping and distinction of elements is weak as well
- Current styling on oz webpage leaves much to be desired. - The table has a weak presence - The buttons are flat and don't fit with the page - Animations are absent - Most actions take you to another page (at least in preview mode) - Preview mode is broken, with activate resulting in a 501 error rather than faking the actions - Grouping and distinction of elements is weak as well
tepichord added this to the MVP project 2026-09-20 17:09:44 -07:00
Author
Owner

Naina:
Updated on 2026-09-21 based on maintainer feedback.

Overview

Oz's console works but reads as unstyled: one hairline under each row, flat buttons, no motion, weak grouping, and every press is a full-page POST. This plan restyles the page on the existing shadcn-svelte CSS variables — bits-ui plus custom CSS only, per the maintainer's follow-up: no shadcn-svelte, no Tailwind, no new dependency — and moves the four /api/* posts into +page.server.ts actions behind use:enhance, so a press updates the table in place. The reported 501 on preview-mode activate is localised and recorded, not guessed at.

Technical Approach

  1. Move the four POST handlers into named actions in oz/src/routes/+page.server.ts.1 2 Keep each body; replace the preview throw redirect(303, '/?preview=…') with a returned notice, and replace every htmlError(...) with fail(status, { notice }).

    import { fail } from '@sveltejs/kit';
    import type { Actions, PageServerLoad } from './$types';
    
    export const actions: Actions = {
      activate: async ({ request, locals }) => {
        const form = await request.formData();
        const pr = String(form.get('pr') || '');
        if (isPreviewBuild()) {
          applySimulated(simulatedState(), 'activate', pr);
          return { notice: `Simulated activate on PR ${pr}.`, pr };
        }
        if (!locals.user || !csrfMatches(locals.user, String(form.get('csrf') || ''))) {
          return fail(403, { notice: 'Rejected.' });
        }
        try {
          const namespace = await new PreviewStore(new KubernetesClient())
            .activate(pr, Math.floor(Date.now() / 1000));
          return { notice: `Activated ${namespace}.`, pr };
        } catch (error: unknown) {
          return fail(400, { notice: error instanceof Error ? error.message : 'Rejected.' });
        }
      },
      build,
      delete: remove, // `delete` is reserved, so the handler is `remove`
      frontDoor
    };
    

    fail and not htmlError: use:enhance posts with accept: application/json, so kit devalue-serializes the return value — a Response throws "not serializable" and surfaces as a 500 error result (kit 2.70.3 actions.js).3 4 htmlError stays in oz/src/lib/http.ts for callback/+server.ts.5 6

    Delete oz/src/routes/api/{build,activate,delete,front-door}/; grep -rn 'action="/api/' oz/ then finds nothing.

  2. Enhance every action form; drop the ?preview=/?pr= branch from load; read the notice from form.

    <script lang="ts">
      import type { ActionData, PageData } from './$types';
      let { data, form }: { data: PageData; form: ActionData } = $props();
    </script>
    
    <form method="post" action="?/activate" use:enhance>
    ...
    {#if form?.notice}<div class="notice">{form?.notice}</div>{/if}
    

    Real-mode Build still redirects to the Woodpecker UI; use:enhance follows an external redirect with a full page load (client.js → native_navigation), so that press keeps opening the pipeline.

  3. One top-level dialog state variable; close it once the action lands. $state cannot be declared inside {#each} (state_invalid_placement).11

    let openPr = $state<string | null>(null);
    ...
    {#each data.rows as row (row.number)}
      <AlertDialog.Root open={openPr === row.number}
                        onOpenChange={(o) => (openPr = o ? row.number : null)}>
      ...
      <form method="post" action="?/delete"
            use:enhance={() => async ({ update }) => { await update(); openPr = null; }}>
    
  4. Table: card, zebra, hover, and a sticky header that actually holds. .table-wrapper needs an explicit height or sticky has no scrollport; separate + box-shadow keeps row lines with the cells.14 15

    .table-wrapper { max-height: 70vh; overflow: auto; border: 1px solid hsl(var(--border));
                     border-radius: var(--radius); background: hsl(var(--card)); }
    table  { border-collapse: separate; border-spacing: 0; width: 100%; }
    th     { position: sticky; top: 0; z-index: 1; background: hsl(var(--card)); }
    th, td { box-shadow: inset 0 -1px 0 hsl(var(--border)); }
    tbody tr:nth-child(even) { background: hsl(var(--muted) / .4); }
    tbody tr:hover           { background: hsl(var(--accent)); }
    .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden;
               clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
    @keyframes oz-settle { from { background: hsl(var(--accent)); } }
    tr[data-changed='true'] { animation: oz-settle 1.2s ease-out; }
    
    <caption class="sr-only">Open pull requests and the preview each one has</caption>
    <th scope="col">PR</th>
    <tr data-changed={form?.pr === row.number}>
    
  5. Button variants via the data-variant hook the stylesheet already expects (dead today).7 bits-ui has no variant system.

    <Button.Root type="submit" data-button-root data-variant="outline">Build</Button.Root>
    <Button.Root type="submit" data-button-root data-variant="destructive">Delete</Button.Root>
    

    Leave Show byte-identical — tests/test_static.py:3231 pins that exact string.13

  6. Complete the dialog. Keep no Portal (the delete <form> lives inside Content, and a portal would move it out of the form); add the missing overlay and the two labelled parts.

    <AlertDialog.Overlay data-alert-dialog-overlay />
    <AlertDialog.Content data-alert-dialog-content>
      <AlertDialog.Title>Delete preview #{row.number}?</AlertDialog.Title>
      <AlertDialog.Description>The reaper collects it within five minutes.</AlertDialog.Description>
    
  7. Motion on data-state, gated on reduced motion. bits-ui waits on getAnimations() before unmounting a closing part, so CSS keyframes alone hold it open.9 10 The keyframes repeat the base translate(-50%, -50%), so the dialog does not jump.

    @keyframes oz-fade-in  { from { opacity: 0 } }
    @keyframes oz-fade-out { to   { opacity: 0 } }
    @keyframes oz-pop-in   { from { opacity: 0; transform: translate(-50%, -50%) scale(.97) } }
    @keyframes oz-pop-out  { to   { opacity: 0; transform: translate(-50%, -50%) scale(.97) } }
    
    [data-alert-dialog-overlay] { position: fixed; inset: 0; z-index: 40;
                                  background: hsl(240 10% 3.9% / .5); }
    [data-alert-dialog-overlay][data-state='open']   { animation: oz-fade-in .15s ease-out; }
    [data-alert-dialog-overlay][data-state='closed'] { animation: oz-fade-out .12s ease-in; }
    [data-alert-dialog-content][data-state='open']   { animation: oz-pop-in .16s ease-out; }
    [data-alert-dialog-content][data-state='closed'] { animation: oz-pop-out .12s ease-in; }
    
    @media (prefers-reduced-motion: reduce) {
      *, *::before, *::after { animation-duration: .01ms !important;
                               transition-duration: .01ms !important; }
    }
    
  8. Repaint the two notice colours — the only hex outside the variable set — and group the action cell.

    .preview-notice { border: 1px solid hsl(38 90% 60% / .5); background: hsl(38 90% 60% / .12); }
    .notice         { border: 1px solid hsl(150 60% 40% / .5); background: hsl(150 60% 40% / .12); }
    .row-actions    { display: flex; gap: .4rem; flex-wrap: wrap; align-items: center; }
    
  9. Localise the 501 and record it; the fix is out of scope here. Nothing in the tree or in kit 2.70.3 emits 501, and every preview branch short-circuits before auth.16 unverified (in-repo half verified; the “kit 2.70.3 contains none” half has no cited source)

    kubectl -n preview-<n> logs deploy/oz | grep 'preview mode: would activate'
    kubectl -n preview-<n> port-forward svc/oz 8080:80 &
    curl -i -d 'pr=58' 'http://localhost:8080/?/activate'
    

    A log line means the press reached the console and the 501 came from a later hop (router/ingress); a non-501 from the port-forward localises it to the proxy. Fix wherever it lands, independently of the styling.

  10. Update the one test that pins the markup — tests/test_static.py:3215.12

    -    started = page.index('action="/api/front-door"')
    +    started = page.index('action="?/frontDoor"')
    

Implementation Details

Path Change
oz/src/routes/+page.server.ts add actions (build/activate/delete/frontDoor); drop the ?preview=/?pr= branch
oz/src/routes/+page.svelte use:enhance, form?.notice, openPr, (row.number) keys, table/dialog/button markup
oz/src/app.css card table + sticky header, variants, overlay + motion, notice tokens, reduced motion
oz/src/routes/api/{build,activate,delete,front-door}/ delete
tests/test_static.py the one anchor above

Constraints:

  • bits-ui + custom CSS only: no shadcn-svelte, no Tailwind, no new dependency; all CSS stays in app.css against hsl(var(--…)).
  • Every form keeps its hidden name="csrf" input; the preview branch returns before it is checked.
  • Keep the Log out form a plain POST — it must navigate.
  • oz/src/lib/selection.ts and expiry.ts stay untouched (pure, shared with the router).

Out-of-scope notes:

  • .dark is dead code — nothing ever adds the class. A theme toggle is its own issue.
  • The 501's fix is out of scope: this change localises and records it.

Acceptance Criteria

  • Build/Activate/Delete/Show submit without a page navigation (URL unchanged) and show the action's notice.
  • oz/src/routes/api/ no longer exists, and nothing references /api/{build,activate,delete,front-door}.
  • The table reads as a card with striped rows, a hover row, and a header that holds while the body scrolls; <th scope="col"> and a <caption> are present.
  • Delete reads destructive, Build outline, the committing action primary — via data-variant; Show is unchanged.
  • The delete dialog dims the page, has a Title and a Description, animates open and closed, and the motion is neutralised under prefers-reduced-motion: reduce.
  • The 501 probe result (or why it could not be run) is recorded on this issue.

Testing Plan

  • cd oz && deno task test — unit suite green; extend src/lib/preview-mode.test.ts only if a new pure helper lands.

  • cd oz && deno task check — Svelte/TS types clean after the actions land.

  • python3 -m pytest tests/test_static.py -q — the updated anchor plus:

    assert 'use:enhance' in page
    assert 'action="?/activate"' in page
    assert '<th scope="col">PR</th>' in page
    assert 'data-variant="destructive"' in page
    assert 'data-alert-dialog-overlay' in page
    assert 'prefers-reduced-motion' in css
    assert '[data-alert-dialog-overlay]' in css
    
  • Manual, on a preview build showing the five fixture rows: press each action and watch its row change with no navigation.

  • Manual: keyboard-open the delete dialog and tab through it; the focus trap, Esc, and the reduced-motion setting are what a screenshot cannot check.

  • #63 — the SvelteKit port this builds on (merged); tests/test_static.py is its markup contract.
  • #59 — "Utilize SvelteKit for Oz", the port's parent issue.
  • docs/preview-environment.md §11h — the status codes the preview router can answer, for step 9.

Research Findings

  • bits-ui 2.19.2 waits on getAnimations() before unmounting a closing part and emits data-state="open"|"closed"; CSS keyframes on those selectors hold the overlay and content open, so no Svelte transition: directive is needed.
  • bits-ui has no variant system — Button.Root exposes only data-button-root — so data-variant is Oz's own hook.8
  • use:enhance needs a same-page action and a devalue-serializable return: posting to a +server.ts endpoint, or returning a Response such as htmlError, makes kit parse non-JSON and error out.
  • use:enhance follows a redirect with a navigation, and an external location becomes a full page load (kit 2.70.3 client.js → native_navigation), which is how real-mode Build keeps opening Woodpecker.
  • position: sticky on <th> needs border-collapse: separate (collapsed borders stay behind) and a scroll container with an explicit height, or the header never holds.
  • No public source explains the 501: kit 2.70.3 contains none, and the preview router and ingress answer 502/503 instead.

Pre-Mortem

  1. The bare Response returns survive the move, so use:enhance hits the error boundary in real mode. The plan only says to convert every htmlError(...) to fail(...), but each of the four handlers also returns a bare new Response(null, { status: 403 }) for CSRF rejection and a 302 with a Location header on success, which use:enhance cannot JSON-parse. No rendered-page test and no cluster in CI means a rejection press shows the error boundary and a success press silently navigates, and it ships green.
  2. The reported 501 is deliberately not fixed. The plan declares the activate 501 out of scope and downgrades the criterion to "record the probe result or why it could not be run", which the pipeline cannot satisfy without cluster access. The issue as filed stays open and a reviewer can reasonably treat the change as incomplete.
  3. The visual criteria are only checked by source-string assertions. tests/test_static.py reads the files rather than rendering them, so the sticky header (which needs .table-wrapper to actually be a scrollport — with five fixture rows under max-height: 70vh it never scrolls and never visibly sticks) and the dialog's keyframe translate(-50%, -50%) alignment are unverified. A plausible-looking diff passes every check while an acceptance criterion is quietly false.
Naina: *Updated on 2026-09-21 based on maintainer feedback.* ## Overview Oz's console works but reads as unstyled: one hairline under each row, flat buttons, no motion, weak grouping, and every press is a full-page POST. This plan restyles the page on the existing shadcn-svelte CSS variables — bits-ui plus custom CSS only, per the maintainer's follow-up: no shadcn-svelte, no Tailwind, no new dependency — and moves the four `/api/*` posts into `+page.server.ts` actions behind `use:enhance`, so a press updates the table in place. The reported 501 on preview-mode activate is localised and recorded, not guessed at. ## Technical Approach 1. **Move the four POST handlers into named actions in `oz/src/routes/+page.server.ts`.**<sup>[1](https://git.gloom.garden/tepichord/milner/src/commit/e0c6123b74a3da6c1b17f5a62e5754f2573e04eb/oz/src/routes/api/activate/+server.ts?display=source#L13) [2](https://git.gloom.garden/tepichord/milner/src/commit/e0c6123b74a3da6c1b17f5a62e5754f2573e04eb/oz/src/routes/+page.server.ts?display=source#L10)</sup> Keep each body; replace the preview `throw redirect(303, '/?preview=…')` with a returned notice, and replace every `htmlError(...)` with `fail(status, { notice })`. ```ts import { fail } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; export const actions: Actions = { activate: async ({ request, locals }) => { const form = await request.formData(); const pr = String(form.get('pr') || ''); if (isPreviewBuild()) { applySimulated(simulatedState(), 'activate', pr); return { notice: `Simulated activate on PR ${pr}.`, pr }; } if (!locals.user || !csrfMatches(locals.user, String(form.get('csrf') || ''))) { return fail(403, { notice: 'Rejected.' }); } try { const namespace = await new PreviewStore(new KubernetesClient()) .activate(pr, Math.floor(Date.now() / 1000)); return { notice: `Activated ${namespace}.`, pr }; } catch (error: unknown) { return fail(400, { notice: error instanceof Error ? error.message : 'Rejected.' }); } }, build, delete: remove, // `delete` is reserved, so the handler is `remove` frontDoor }; ``` `fail` and not `htmlError`: `use:enhance` posts with `accept: application/json`, so kit devalue-serializes the return value — a `Response` throws "not serializable" and surfaces as a 500 error result (kit 2.70.3 `actions.js`).<sup>[3](https://svelte.dev/docs/kit/form-actions) [4](https://github.com/sveltejs/kit/issues/8523)</sup> `htmlError` stays in `oz/src/lib/http.ts` for `callback/+server.ts`.<sup>[5](https://git.gloom.garden/tepichord/milner/src/commit/e0c6123b74a3da6c1b17f5a62e5754f2573e04eb/oz/src/lib/http.ts?display=source#L19) [6](https://git.gloom.garden/tepichord/milner/src/commit/e0c6123b74a3da6c1b17f5a62e5754f2573e04eb/oz/src/routes/callback/+server.ts?display=source#L8)</sup> Delete `oz/src/routes/api/{build,activate,delete,front-door}/`; `grep -rn 'action="/api/' oz/` then finds nothing. 2. **Enhance every action form; drop the `?preview=`/`?pr=` branch from `load`; read the notice from `form`.** ```svelte <script lang="ts"> import type { ActionData, PageData } from './$types'; let { data, form }: { data: PageData; form: ActionData } = $props(); </script> <form method="post" action="?/activate" use:enhance> ... {#if form?.notice}<div class="notice">{form?.notice}</div>{/if} ``` Real-mode Build still redirects to the Woodpecker UI; `use:enhance` follows an external redirect with a full page load (`client.js` → `native_navigation`), so that press keeps opening the pipeline. 3. **One top-level dialog state variable; close it once the action lands.** `$state` cannot be declared inside `{#each}` (`state_invalid_placement`).<sup>[11](https://svelte.dev/docs/svelte/compiler-errors#state_invalid_placement)</sup> ```svelte let openPr = $state<string | null>(null); ... {#each data.rows as row (row.number)} <AlertDialog.Root open={openPr === row.number} onOpenChange={(o) => (openPr = o ? row.number : null)}> ... <form method="post" action="?/delete" use:enhance={() => async ({ update }) => { await update(); openPr = null; }}> ``` 4. **Table: card, zebra, hover, and a sticky header that actually holds.** `.table-wrapper` needs an explicit height or `sticky` has no scrollport; `separate` + `box-shadow` keeps row lines with the cells.<sup>[14](https://git.gloom.garden/tepichord/milner/src/commit/e0c6123b74a3da6c1b17f5a62e5754f2573e04eb/oz/src/app.css?display=source#L69) [15](https://git.gloom.garden/tepichord/milner/src/commit/e0c6123b74a3da6c1b17f5a62e5754f2573e04eb/oz/src/app.css?display=source#L64)</sup> ```css .table-wrapper { max-height: 70vh; overflow: auto; border: 1px solid hsl(var(--border)); border-radius: var(--radius); background: hsl(var(--card)); } table { border-collapse: separate; border-spacing: 0; width: 100%; } th { position: sticky; top: 0; z-index: 1; background: hsl(var(--card)); } th, td { box-shadow: inset 0 -1px 0 hsl(var(--border)); } tbody tr:nth-child(even) { background: hsl(var(--muted) / .4); } tbody tr:hover { background: hsl(var(--accent)); } .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } @keyframes oz-settle { from { background: hsl(var(--accent)); } } tr[data-changed='true'] { animation: oz-settle 1.2s ease-out; } ``` ```svelte <caption class="sr-only">Open pull requests and the preview each one has</caption> <th scope="col">PR</th> <tr data-changed={form?.pr === row.number}> ``` 5. **Button variants via the `data-variant` hook the stylesheet already expects (dead today).**<sup>[7](https://git.gloom.garden/tepichord/milner/src/commit/e0c6123b74a3da6c1b17f5a62e5754f2573e04eb/oz/src/app.css?display=source#L115)</sup> bits-ui has no variant system. ```svelte <Button.Root type="submit" data-button-root data-variant="outline">Build</Button.Root> <Button.Root type="submit" data-button-root data-variant="destructive">Delete</Button.Root> ``` Leave `Show` byte-identical — `tests/test_static.py:3231` pins that exact string.<sup>[13](https://git.gloom.garden/tepichord/milner/src/commit/e0c6123b74a3da6c1b17f5a62e5754f2573e04eb/tests/test_static.py?display=source#L3231)</sup> 6. **Complete the dialog.** Keep no `Portal` (the delete `<form>` lives inside `Content`, and a portal would move it out of the form); add the missing overlay and the two labelled parts. ```svelte <AlertDialog.Overlay data-alert-dialog-overlay /> <AlertDialog.Content data-alert-dialog-content> <AlertDialog.Title>Delete preview #{row.number}?</AlertDialog.Title> <AlertDialog.Description>The reaper collects it within five minutes.</AlertDialog.Description> ``` 7. **Motion on `data-state`, gated on reduced motion.** bits-ui waits on `getAnimations()` before unmounting a closing part, so CSS keyframes alone hold it open.<sup>[9](https://www.bits-ui.com/docs/transitions) [10](https://www.bits-ui.com/docs/components/dialog)</sup> The keyframes repeat the base `translate(-50%, -50%)`, so the dialog does not jump. ```css @keyframes oz-fade-in { from { opacity: 0 } } @keyframes oz-fade-out { to { opacity: 0 } } @keyframes oz-pop-in { from { opacity: 0; transform: translate(-50%, -50%) scale(.97) } } @keyframes oz-pop-out { to { opacity: 0; transform: translate(-50%, -50%) scale(.97) } } [data-alert-dialog-overlay] { position: fixed; inset: 0; z-index: 40; background: hsl(240 10% 3.9% / .5); } [data-alert-dialog-overlay][data-state='open'] { animation: oz-fade-in .15s ease-out; } [data-alert-dialog-overlay][data-state='closed'] { animation: oz-fade-out .12s ease-in; } [data-alert-dialog-content][data-state='open'] { animation: oz-pop-in .16s ease-out; } [data-alert-dialog-content][data-state='closed'] { animation: oz-pop-out .12s ease-in; } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; } } ``` 8. **Repaint the two notice colours — the only hex outside the variable set — and group the action cell.** ```css .preview-notice { border: 1px solid hsl(38 90% 60% / .5); background: hsl(38 90% 60% / .12); } .notice { border: 1px solid hsl(150 60% 40% / .5); background: hsl(150 60% 40% / .12); } .row-actions { display: flex; gap: .4rem; flex-wrap: wrap; align-items: center; } ``` 9. **Localise the 501 and record it; the fix is out of scope here.** Nothing in the tree or in kit 2.70.3 emits 501, and every preview branch short-circuits before auth.<sup>[16](https://git.gloom.garden/tepichord/milner/src/commit/e0c6123b74a3da6c1b17f5a62e5754f2573e04eb/oz/src/routes/api/activate/+server.ts?display=source#L19)</sup> _<sup>unverified</sup>_ (in-repo half verified; the “kit 2.70.3 contains none” half has no cited source) ```bash kubectl -n preview-<n> logs deploy/oz | grep 'preview mode: would activate' kubectl -n preview-<n> port-forward svc/oz 8080:80 & curl -i -d 'pr=58' 'http://localhost:8080/?/activate' ``` A log line means the press reached the console and the 501 came from a later hop (router/ingress); a non-501 from the port-forward localises it to the proxy. Fix wherever it lands, independently of the styling. 10. **Update the one test that pins the markup** — `tests/test_static.py:3215`.<sup>[12](https://git.gloom.garden/tepichord/milner/src/commit/e0c6123b74a3da6c1b17f5a62e5754f2573e04eb/tests/test_static.py?display=source#L3215)</sup> ```diff - started = page.index('action="/api/front-door"') + started = page.index('action="?/frontDoor"') ``` ## Implementation Details | Path | Change | | --- | --- | | `oz/src/routes/+page.server.ts` | add `actions` (build/activate/delete/frontDoor); drop the `?preview=`/`?pr=` branch | | `oz/src/routes/+page.svelte` | `use:enhance`, `form?.notice`, `openPr`, `(row.number)` keys, table/dialog/button markup | | `oz/src/app.css` | card table + sticky header, variants, overlay + motion, notice tokens, reduced motion | | `oz/src/routes/api/{build,activate,delete,front-door}/` | delete | | `tests/test_static.py` | the one anchor above | Constraints: - bits-ui + custom CSS only: no shadcn-svelte, no Tailwind, no new dependency; all CSS stays in `app.css` against `hsl(var(--…))`. - Every form keeps its hidden `name="csrf"` input; the preview branch returns before it is checked. - Keep the `Log out` form a plain POST — it must navigate. - `oz/src/lib/selection.ts` and `expiry.ts` stay untouched (pure, shared with the router). Out-of-scope notes: - `.dark` is dead code — nothing ever adds the class. A theme toggle is its own issue. - The 501's fix is out of scope: this change localises and records it. ## Acceptance Criteria - [ ] Build/Activate/Delete/Show submit without a page navigation (URL unchanged) and show the action's notice. - [ ] `oz/src/routes/api/` no longer exists, and nothing references `/api/{build,activate,delete,front-door}`. - [ ] The table reads as a card with striped rows, a hover row, and a header that holds while the body scrolls; `<th scope="col">` and a `<caption>` are present. - [ ] Delete reads destructive, Build outline, the committing action primary — via `data-variant`; `Show` is unchanged. - [ ] The delete dialog dims the page, has a `Title` and a `Description`, animates open and closed, and the motion is neutralised under `prefers-reduced-motion: reduce`. - [ ] The 501 probe result (or why it could not be run) is recorded on this issue. ## Testing Plan - `cd oz && deno task test` — unit suite green; extend `src/lib/preview-mode.test.ts` only if a new pure helper lands. - `cd oz && deno task check` — Svelte/TS types clean after the actions land. - `python3 -m pytest tests/test_static.py -q` — the updated anchor plus: ```python assert 'use:enhance' in page assert 'action="?/activate"' in page assert '<th scope="col">PR</th>' in page assert 'data-variant="destructive"' in page assert 'data-alert-dialog-overlay' in page assert 'prefers-reduced-motion' in css assert '[data-alert-dialog-overlay]' in css ``` - Manual, on a preview build showing the five fixture rows: press each action and watch its row change with no navigation. - Manual: keyboard-open the delete dialog and tab through it; the focus trap, `Esc`, and the reduced-motion setting are what a screenshot cannot check. ## Related Issues/PRs - #63 — the SvelteKit port this builds on (merged); `tests/test_static.py` is its markup contract. - #59 — "Utilize SvelteKit for Oz", the port's parent issue. - `docs/preview-environment.md` §11h — the status codes the preview router can answer, for step 9. ## Research Findings - bits-ui 2.19.2 waits on `getAnimations()` before unmounting a closing part and emits `data-state="open"|"closed"`; CSS keyframes on those selectors hold the overlay and content open, so no Svelte `transition:` directive is needed. - bits-ui has no variant system — `Button.Root` exposes only `data-button-root` — so `data-variant` is Oz's own hook.<sup>[8](https://www.bits-ui.com/docs/components/button)</sup> - `use:enhance` needs a same-page action and a devalue-serializable return: posting to a `+server.ts` endpoint, or returning a `Response` such as `htmlError`, makes kit parse non-JSON and error out. - `use:enhance` follows a redirect with a navigation, and an external `location` becomes a full page load (kit 2.70.3 `client.js` → `native_navigation`), which is how real-mode Build keeps opening Woodpecker. - `position: sticky` on `<th>` needs `border-collapse: separate` (collapsed borders stay behind) and a scroll container with an explicit height, or the header never holds. - No public source explains the 501: kit 2.70.3 contains none, and the preview router and ingress answer 502/503 instead. ## Pre-Mortem 1. **The bare `Response` returns survive the move, so `use:enhance` hits the error boundary in real mode.** The plan only says to convert every `htmlError(...)` to `fail(...)`, but each of the four handlers also returns a bare `new Response(null, { status: 403 })` for CSRF rejection and a `302` with a `Location` header on success, which `use:enhance` cannot JSON-parse. No rendered-page test and no cluster in CI means a rejection press shows the error boundary and a success press silently navigates, and it ships green. 2. **The reported 501 is deliberately not fixed.** The plan declares the activate 501 out of scope and downgrades the criterion to "record the probe result or why it could not be run", which the pipeline cannot satisfy without cluster access. The issue as filed stays open and a reviewer can reasonably treat the change as incomplete. 3. **The visual criteria are only checked by source-string assertions.** `tests/test_static.py` reads the files rather than rendering them, so the sticky header (which needs `.table-wrapper` to actually be a scrollport — with five fixture rows under `max-height: 70vh` it never scrolls and never visibly sticks) and the dialog's keyframe `translate(-50%, -50%)` alignment are unverified. A plausible-looking diff passes every check while an acceptance criterion is quietly false.
Author
Owner

We deliberately avoided shadcn because of the package size, everything I requested I would like to be done with Bits UI and custom CSS classes. Not by bringing in shadcn and the large runtime expansion that necessitates.

We deliberately avoided shadcn because of the package size, everything I requested I would like to be done with Bits UI and custom CSS classes. Not by bringing in shadcn and the large runtime expansion that necessitates.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
tepichord/milner#75
No description provided.