docs(10): capture phase context

This commit is contained in:
Jakub Zych
2026-09-26 23:21:43 +02:00
parent 13760e3a1a
commit 2efe51a168
5 changed files with 2998 additions and 0 deletions

View File

@@ -0,0 +1,149 @@
# Phase 10: Admin Vue SPA - Context
**Gathered:** 2026-09-26
**Status:** Ready for planning (after Phase 9 plans 09-11 and 09-12 complete)
<domain>
## Phase Boundary
Phase 10 delivers the framework's admin SPA: a Vue 3 + TypeScript single-page app, living in `summercms.go`, embedded in the binary and served at a configurable backend URI. It renders login, permission-gated plugin navigation, schema-driven lists and forms, the relation manager, the settings screen and the filter bar, consuming the Phase 9 `cabana` admin API. The five Płytarium controllers (Albums, Artists, Collections, Genres, Styles) are the acceptance targets; the Collections editors relation manager must search, link and unlink.
The phase also carries the backend changes the SPA needs, all in `cabana` (framework) plus YAML/registry updates in `fonoteka.go`: a configurable admin prefix for both SPA and API, cookie transport for the admin JWT, a relation-field options endpoint, relation values in form saves, `messages:` and declarative `toolbar.buttons` in controller config, a server-served SPA string bundle, and a framework-owned admin OpenAPI document the SPA's types are generated from.
Requirement: ADMIN-06. Not in scope: runtime plugin extensions (plugin JS/CSS assets, custom widget and partial field types), which are Phase 10.1; browser e2e tests.
</domain>
<decisions>
## Implementation Decisions
### Packaging and serving
- **D-01:** The SPA source lives in `summercms.go/admin` (Vite project). Its built `dist/` is embedded through `embed.FS` in a framework package and served by the binary. No separate deploy, no CORS.
- **D-02:** The mount path is configurable (`backend.uri`, Winter-style), e.g. `/plytadmin`, `/manage`, `/horoadmin`, chosen per project so the admin location is not guessable. The committed build is path-agnostic: the Go server injects the configured base path into `index.html` at serve time. Client-side routes are handled by an SPA fallback under the prefix. — **Reversibility:** reversible
- **D-03:** The admin API moves with the prefix: `{backend.uri}/api/v1/...` replaces the hardcoded `/_admin/api/v1` from Phase 9 (D-09 there). This is safe because nothing outside the SPA calls the admin API yet (Nuxt and fonoteka-mcp never touch it). The `api` segment under the prefix is reserved and never used as an SPA route. OpenAPI paths are written relative (`/navigation`, `/auth/login`) and the SPA client gets its base URL at runtime. — **Reversibility:** costly — every admin route, test and the OpenAPI document are keyed to the prefix scheme.
- **D-04:** The built `dist/` is committed, with a check script that rebuilds and fails on drift (same pattern as `check-openapi.sh`). Rationale agreed with the user: everything an app plugin changes in the admin (YAML columns/fields, navigation, permissions, hooks, and in 10.1 partials and JS/CSS assets) is embedded in the plugin and needs only a Go binary rebuild; the Node build is needed only when the framework SPA itself changes (new built-in field type, layout). App developers never need Node.
- **D-05:** Extension seam only: a field-type renderer registry (`FieldRenderer` maps `type` to component). Any unregistered type renders the design's `UnsupportedField` box ("Nieobsługiwany typ pola: `<type>`") instead of breaking the form. The full extension point is Phase 10.1 (see Deferred).
### Look and components
- **D-06:** The design reference is Direction C v2 from claude.ai/design, stored at `.planning/phases/10-admin-vue-spa/design/` (README.md is the spec; `Direction C v2.dc.html` + `support.js` is the viewable reference). High fidelity: tokens, typography, radii, spacing, control heights, focus rings, copy, a11y roles, states (selected, empty, loading, 422, toast, modal), responsive rules (collapse below ~1100px, tablet width), light and dark mode. Build the generic components it lists (PluginRail, SectionPanel, SectionFlyout, AppShell, DataTable, ListToolbar, Pagination, FormTabs, FormGrid, FormField, FieldRenderer, RelationManager, RelationPickerModal, Toast, UserMenu).
- **D-07:** Stack: Vue 3 + Vite + TypeScript + Tailwind + Reka UI primitives + lucide-vue-next icons (same family as `vue-fonoteka-app`). DM Sans and DM Mono are self-hosted inside `dist/`, not loaded from Google Fonts (works offline, no third-party requests from a hidden admin URL).
- **D-08:** Real YAML only. Płytarium screens render exactly what their YAML declares (Albums: name, artists, format, genre, shelf; no tabs). The mock's extra fields, tabs and columns (Opis, Wypożyczony, Ulubiony, Utworzono, Szczegóły tab) are component fixtures for tests, not additions to Płytarium.
- **D-09:** The SPA follows Phase 9's API contract where the mock differs: error envelope `{"error": {code, message, details}}` with 422 details as field → messages (not the mock's `{errors}`); list query and meta per Phase 9 D-11.
- **D-10:** Routes derive from controller IDs: `golem15.fonoteka.albums` → `{backend.uri}/golem15/fonoteka/albums`, `/create`, `/:id`. The rail groups by plugin from `/navigation`; the active rail item comes from the route's owning plugin. Items the admin lacks permission for are absent (server-filtered), and a plugin whose side menu is empty after filtering is absent from the rail.
- **D-11:** Navigation icons are lucide names in the registry. The SPA also carries a small map from Winter `icon-*` names to lucide so ported Winter plugins work unchanged; unknown names get a neutral fallback icon. `fonoteka.go` switches its navigation and settings icons to lucide names.
- **D-12:** The Albums format column renders as plain text in Phase 10 (no icon pills).
### Controller copy and toolbar
- **D-13:** `config_list.yaml` and `config_form.yaml` accept an optional `messages:` block of phrasebook keys (e.g. `recordCount`, `searchPrompt`, `deleteConfirm`, `empty` on lists; `create`, `saved` on forms). Keys are resolved server-side in the admin's locale. Plural keys are served with all their CLDR forms (`{"one": ..., "few": ..., "many": ..., "other": ...}`) and the SPA picks the form with `Intl.PluralRules(locale)`, because only the client knows the count (e.g. selected rows). Placeholders `{count}`, `{name}`, `{term}` are interpolated by the SPA. Every key is optional; the framework ships generic defaults (e.g. "Nowy rekord", "Usunąć zaznaczone ({count})?", "Zapisano"). Unknown `messages` keys fail at boot (Phase 9 `DisallowUnknownField` rule).
- **D-14:** Toolbar is declarative: `toolbar.buttons: [create, delete]` lists built-in actions in display order (`create` gated by create permission; `delete` is bulk delete, enabled with a selection, confirming with `deleteConfirm`), plus `toolbar.search`. A string value (Winter's `buttons: list_toolbar` partial) is a boot error pointing at the new syntax. The five Płytarium config files are updated accordingly. Custom actions are Phase 10.1. — **Reversibility:** costly — changes the YAML contract every ported controller writes.
### Types and relations
- **D-15:** The SPA's types come from a framework-owned admin OpenAPI document: swag v1 annotations on `cabana` handlers, generated in `summercms.go`, converted to OpenAPI 3 (reuse the `swagger2openapi.go` approach), committed, and fed to openapi-typescript, with a drift check. The framework SPA never reads an app repo's document. `fonoteka.go/docs/openapi.json` remains the parity API document.
- **D-16:** Records are typed generically: generated types cover envelopes, form/list/filter/relation/navigation/settings schemas, list meta and errors; a record is `Record<string, unknown>` read through its field schema. No per-controller TypeScript types and no hand-maintained duplicates of API shapes (success criterion 4).
- **D-17:** Relation fields get their choices from a new endpoint, `GET {prefix}/api/v1/{vendor}/{plugin}/{controller}/fields/{field}/options?search=&page=&per_page=`, returning `{value, label}` with the label from `nameFrom`, on the Phase 9 D-11 list contract. An optional `RelationExtendOptionsQuery(ctx, field, *gorm.DB) *gorm.DB` hook on the admin controller lets apps scope options (e.g. to the active collection). Permission gating is the controller's. The `emptyOption` stays a schema property rendered by the SPA.
- **D-18:** Form saves send relation values keyed by the YAML field name with ids: `{"genre": 3, "artists": [4, 9]}`. `cabana` maps belongs-to to the foreign key and syncs many-to-many through the Phase 5 join-table contract inside the save transaction, after `FormBeforeCreate`/`FormBeforeUpdate`. Record GET returns the same shape plus display labels so the form can show chips and the selected dropdown value without extra calls.
### Session and UX
- **D-19:** The admin JWT moves into an httpOnly, Secure, SameSite=Strict cookie scoped to `{backend.uri}`, set by login and refresh and cleared by logout. JS never reads the token (this matters once 10.1 lets plugin JS run in the admin origin). The `backend` guard accepts the cookie and still accepts `Authorization: Bearer` for CLI and tests. State-changing requests must carry a custom header (e.g. `X-Requested-With`) as CSRF defence in depth. It is still a JWT with the Phase 7 blacklist and sliding refresh; no session store. — **Reversibility:** costly — changes Phase 9's auth transport and the security matrix tests that pin it.
- **D-20:** The SPA's own UI strings (Zapisz, Anuluj, Wyloguj, pagination, confirmations, defaults from D-13) live in the framework's phrasebook (`backend::lang`, pl and en) and are fetched as a resolved bundle for the admin's locale at startup. Projects can override or add locales without a Node rebuild.
- **D-21:** The settings screen is in scope: the rail's "Ustawienia" lists `HasSettings` pages the admin may manage and renders each through the same form renderer against the Phase 9 settings endpoints (fonoteka: `search_use_typesense`).
- **D-22:** The filter bar is in scope: it renders `config_filter.yaml` scopes of the three Phase 9 shapes (switch, daterange, model-backed) and drives `filter[<scope>]`. Płytarium has no filters, so it is verified with fixtures.
- **D-23:** Testing follows the lean rules: Vitest component and unit tests as the phase's last plan; Go tests for every `cabana` change. Browser e2e (Playwright) is not part of Phase 10.
### Claude's Discretion
- SPA state management (Pinia or composables), router setup, HTTP client (openapi-fetch or a thin wrapper over generated types).
- Dev loop: Vite dev server proxying the API vs rebuilding into the embedded path; recommended a Vite proxy to a running `summer serve` for development.
- Exact `messages` key vocabulary beyond the examples, and the generic default wording.
- Refresh timing (proactive before expiry plus one retry on 401, then redirect to login with the return URL).
- Per-page options (design says 12/24/48/96; reconcile with `recordsPerPage` and the Phase 9 cap).
- Unsaved-changes confirmation, create-then-redirect behaviour (use `config_form` `create.redirect`/`redirectClose` semantics mapped to SPA routes), toast queue.
- Whether admin paths are removed from `fonoteka.go/docs/openapi.json` once the framework document exists (recommended: yes, one owner per path).
- Package layout inside `summercms.go` for the embed/serve package, following beach-themed naming.
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Design
- `.planning/phases/10-admin-vue-spa/design/README.md` — the full UI spec: tokens, screens, states, interactions, component list.
- `.planning/phases/10-admin-vue-spa/design/Direction C v2.dc.html` (+ `support.js`) — viewable reference; props switch screens and states.
### Roadmap and requirements
- `.planning/ROADMAP.md` §Phase 10 — goal and success criteria.
- `.planning/REQUIREMENTS.md` — ADMIN-06.
- `.planning/PROJECT.md` — two-repo split (framework holds the admin SPA shell), stack constraints.
- `.planning/research/STACK.md` — swag v1, openapi-typescript, goccy/go-yaml.
### Phase 9 contract this phase consumes and amends
- `.planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-CONTEXT.md` — D-02 guard, D-06 typed schema, D-07 server-side labels, D-09/D-10/D-11 API paths, envelope and list contract, D-12 filters, D-13 hooks, D-15/D-16 relation manager, D-17 settings, D-18 navigation.
- `.planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-11-PLAN.md`, `09-12-PLAN.md` and their SUMMARYs once written — final state of navigation/settings endpoints, security matrix and OpenAPI generation.
- `cabana/http.go` — route mounting (hardcoded `/_admin/api/v1` changed by D-03).
- `cabana/auth.go` — login/refresh/logout/me (transport changed by D-19).
- `cabana/form_schema.go`, `cabana/list_schema.go`, `cabana/filter_schema.go`, `cabana/relation.go`, `cabana/settings.go`, `cabana/navigation.go`, `cabana/schema_types.go` — the JSON the SPA renders.
### OpenAPI tooling
- `../fonoteka.go/scripts/check-openapi.sh` and `../fonoteka.go/scripts/swagger2openapi.go` — the generation/conversion/validation pattern to replicate for the framework document.
### Płytarium config the SPA renders
- `../fonoteka.go/plugins/golem15/fonoteka/controllers/{albums,artists,collections,genres,styles}/` — config_form/config_list (updated by D-13/D-14).
- `../fonoteka.go/plugins/golem15/fonoteka/admin_navigation.go`, `admin_settings.go` — icons switched to lucide (D-11).
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/album/fields.yaml`, `columns.yaml` — PHP originals (real field set, D-08).
### UI stack reference
- `/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/package.json` — the Vue/Tailwind/Reka/lucide versions the team already uses.
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `cabana` (~10k lines): complete admin API: auth, navigation, list/form/filter/relation schemas, CRUD, bulk delete, relation linked/candidates/link/unlink, settings. The SPA is its first client.
- `bouncer.MintAudience` / `RefreshAudience` / blacklist: stay the token mechanism under cookie transport (D-19).
- `phrasebook`: resolves labels and the new `messages` and SPA string bundle (D-13, D-20); CLDR plural data matches `Intl.PluralRules`.
- `pact.DropdownOptionsProvider` and model-method options: pattern for the new relation options hook (D-17).
- Phase 5 join-table contract: many-to-many sync on save (D-18).
- `check-openapi.sh` pipeline (swag v1.16.6 → swagger2openapi → openapi-typescript 7.13.0).
### Established Patterns
- Optional capability interfaces type-asserted by the consumer (`RelationExtendOptionsQuery` follows Phase 9 D-13).
- Fail-loud boot on malformed plugin YAML (`DisallowUnknownField`): applies to `messages` and `toolbar.buttons`.
- Committed generated artifact + drift check (OpenAPI doc; now also `dist/` and the admin OpenAPI doc).
- Framework stays app-agnostic: no Płytarium names in `summercms.go`, including SPA code.
### Integration Points
- `cabana` mount: prefix from config (D-03), SPA static serving + index.html base injection + history fallback (D-01, D-02), same prefix.
- `backend` guard in the bouncer registry: cookie extraction + CSRF header check (D-19).
- `/auth/me` already returns name/email; the user menu needs the role name too.
- `fonoteka.go`: YAML toolbar/messages updates, lucide icons, optional `RelationExtendOptionsQuery` on Albums (scope to active collection, consistent with Phase 9 D-14).
</code_context>
<specifics>
## Specific Ideas
- The user wants the admin URL changeable per project for security ("/plytadmin", "/manage", "/horoadmin"), as in WinterCMS.
- The user asked for "a best solution" to per-controller copy instead of Winter's `_list_toolbar.htm` partial: declarative actions plus translatable messages with proper Polish plurals.
- The design was produced in claude.ai/design from a prompt listing three directions; the user chose Direction C v2 ("Generic modern dashboard", navy + sunny yellow) and treats it as the target.
- The shell is multi-plugin by design (rail per registered main-menu item); Płytarium is one plugin among future ones (Użytkownicy, Media in the mock).
</specifics>
<deferred>
## Deferred Ideas
- **Phase 10.1: runtime admin extension point.** `AdminAssets()` on controllers (Winter `addJs`/`addCss`), served under the admin prefix and loaded when the controller opens; `type: widget` rendered as custom elements (plugins ship plain JS, no Vue coupling); `type: partial` rendered server-side via html/template; custom toolbar actions; a dev-mode switch serving plugin assets from disk. Needs a roadmap entry.
- Ctrl+K command palette across all plugins' side menus.
- Generic badge/icon column type (Albums format pills with disc/cassette icons).
- Playwright browser e2e for the admin SPA (joins the Phase 8 carried-forward Playwright follow-up).
- Admin navigation for the user and media plugins (shown in the mock) arrives with those ports.
</deferred>
---
*Phase: 10-admin-vue-spa*
*Context gathered: 2026-09-26*

View File

@@ -0,0 +1,90 @@
# Phase 10: Admin Vue SPA - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
**Date:** 2026-09-26
**Phase:** 10-admin-vue-spa
**Areas discussed:** Packaging & serving, UI kit & look, Types & relation options, Session & UX details
---
## Packaging & serving
| Option | Description | Selected |
|--------|-------------|----------|
| summercms.go/admin, embedded | Vite project in framework repo, dist embedded and served by the binary | ✓ |
| summercms.go/admin, served separately | Static deploy, CORS | |
| fonoteka.go/admin | SPA in the app repo | |
**User's choice:** Embedded, with the admin path configurable per project (e.g. /plytadmin, /manage) for security, as in WinterCMS.
| Option | Description | Selected |
|--------|-------------|----------|
| Commit dist | `go build` stays pure Go, drift check | ✓ |
| Build via summer build | dist gitignored, Node needed at every build | |
**User's choice:** Commit dist, after clarification. The user asked twice whether plugin changes (and Winter's `partial` field, `addJs`/`addCss`) need a rebuild. Resolved: they need a Go binary rebuild (plugin assets are embedded), never a framework SPA Node rebuild once the 10.1 extension point exists; Node is only for framework SPA changes.
| Option | Description | Selected |
|--------|-------------|----------|
| Both SPA and API move with backend.uri | Nothing reveals the admin location | ✓ |
| Only the SPA moves | Fixed /_admin/api/v1 | |
**User's choice:** Both move, conditional on it being safe; confirmed non-breaking (no external admin API consumer yet).
| Option | Description | Selected |
|--------|-------------|----------|
| Seam now, build later | Renderer registry + unsupported fallback now | ✓ |
| Build it in Phase 10 | Assets, widgets, partials now | |
| Only AdminAssets now | | |
**User's choice:** Seam now, full implementation in Phase 10.1.
---
## UI kit & look
The user generated three directions in claude.ai/design from a prompt Claude wrote, and chose Direction C v2 (handoff zip reviewed and copied to `design/`).
| Question | Options | Selected |
|----------|---------|----------|
| Mock vs real YAML | Real YAML only / Extend Płytarium YAML | Real YAML only |
| Nav icons | Lucide + Winter map / Lucide only / Winter names mapped | Lucide + Winter map |
| Format badge icons | Plain text now / Badge column now | Plain text now |
| Per-controller copy | Config keys + generic fallback / Generic only | User asked for the best solution beyond Winter's toolbar partial; Claude proposed `messages:` with CLDR plural forms chosen client-side via Intl.PluralRules plus declarative `toolbar.buttons`; locked |
---
## Types & relation options
| Question | Options | Selected |
|----------|---------|----------|
| OpenAPI source | Framework-owned admin doc / Fonoteka's doc | Framework-owned admin doc |
| Record typing | Generic schema-driven / Per-controller DTOs | Generic schema-driven |
| Relation field options | Options endpoint / Embed in schema / Hybrid | Options endpoint |
| Relation save payload | Field name with ids / Raw columns | Field name with ids |
---
## Session & UX details
| Question | Options | Selected |
|----------|---------|----------|
| Token storage | httpOnly cookie / sessionStorage bearer / localStorage bearer | httpOnly cookie |
| SPA chrome strings | Server phrasebook bundle / Bundled in SPA | Server phrasebook bundle |
| Extras in scope | Settings screen / Filter bar / Playwright e2e | Settings screen, Filter bar |
---
## Claude's Discretion
State management, router, HTTP client, dev loop, messages vocabulary and default wording, refresh timing, per-page options, dirty-form handling, redirects, removal of admin paths from the fonoteka OpenAPI doc, package layout.
## Deferred Ideas
- Phase 10.1 runtime extension point (AdminAssets, `type: widget`, `type: partial`, custom toolbar actions, dev-mode disk assets)
- Ctrl+K command palette
- Badge/icon column type
- Playwright e2e for the admin SPA
- User/media plugin admin navigation

View File

@@ -0,0 +1,461 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600;9..40,700&amp;family=DM+Mono:wght@400;500&amp;display=swap">
<link rel="stylesheet" href="https://unpkg.com/lucide-static@0.460.0/font/lucide.css">
<style>html,body{margin:0;height:100%}*{box-sizing:border-box}input::placeholder,textarea::placeholder{color:var(--placeholder)}a{color:inherit;text-decoration:none}a:hover{text-decoration:underline}</style>
</helmet>
<div style="--bg:{{ t.bg }};--surface:{{ t.surface }};--subtle:{{ t.subtle }};--border:{{ t.border }};--border-strong:{{ t.borderStrong }};--text:{{ t.text }};--muted:{{ t.muted }};--placeholder:{{ t.placeholder }};--primary:{{ t.primary }};--on-primary:{{ t.onPrimary }};--danger:{{ t.danger }};--danger-soft:{{ t.dangerSoft }};--ok-bg:{{ t.okBg }};--ok-text:{{ t.okText }};--ring:{{ t.ring }};--hover:{{ t.hover }};--sel:{{ t.sel }};--skel:{{ t.skel }};--overlay:{{ t.overlay }};--side:{{ t.side }};--side-border:{{ t.sideBorder }};--accent:#fcd34d;--on-accent:#1b2540;--shadow:{{ t.shadow }};color-scheme:{{ t.scheme }};position:relative;width:100%;height:100%;overflow:hidden;display:flex;background:var(--bg);color:var(--text);font-family:'DM Sans',system-ui,sans-serif;font-size:14px;line-height:1.5">
<sc-if value="{{ isLogin }}" hint-placeholder-val="{{ false }}">
<main style="flex:1;display:flex;align-items:center;justify-content:center;padding:24px;background:radial-gradient(ellipse at 50% 35%,#2b3a5c 0%,#1d2740 60%)">
<div style="width:100%;max-width:400px;display:flex;flex-direction:column;align-items:center;gap:24px">
<div style="display:flex;align-items:center;gap:10px;font-weight:700;font-size:18px;color:#ffffff;letter-spacing:-0.01em"><span style="width:34px;height:34px;border-radius:50%;background:rgba(252,211,77,.14);display:inline-flex;align-items:center;justify-content:center"><i class="icon-sun" style="font-size:20px;color:var(--accent)"></i></span><span>Summer<span style="color:var(--accent)">CMS</span></span></div>
<form style="width:100%;background:var(--surface);border-radius:16px;padding:32px;display:flex;flex-direction:column;gap:18px;box-shadow:0 24px 60px rgba(0,0,0,.35)">
<h1 style="margin:0;font-size:22px;font-weight:700;letter-spacing:-0.02em">Witaj ponownie</h1>
<div style="display:flex;flex-direction:column;gap:6px">
<label for="c-login" style="font-weight:500">Login lub e-mail</label>
<input id="c-login" defaultValue="tomasz.wojcik" style="height:44px;padding:0 14px;border:1px solid var(--border-strong);border-radius:10px;background:var(--surface);color:var(--text);font:inherit;outline:none;width:100%" style-focus="border-color:var(--primary);box-shadow:0 0 0 3px var(--ring)">
</div>
<div style="display:flex;flex-direction:column;gap:6px">
<label for="c-pass" style="font-weight:500">Hasło</label>
<input id="c-pass" type="password" defaultValue="winyl1970" aria-invalid="true" aria-describedby="c-err" style="height:44px;padding:0 14px;border:1px solid var(--danger);border-radius:10px;background:var(--surface);color:var(--text);font:inherit;outline:none;width:100%;box-shadow:0 0 0 3px var(--ring)">
</div>
<div id="c-err" role="alert" style="display:flex;gap:10px;align-items:center;padding:12px 14px;border-radius:10px;background:var(--danger-soft);color:var(--danger);font-weight:500"><i class="icon-circle-alert" style="font-size:18px"></i>Nieprawidłowe dane logowania</div>
<button type="submit" style="height:44px;border-radius:10px;border:0;background:var(--primary);color:var(--on-primary);font:inherit;font-weight:600;cursor:pointer" style-hover="opacity:.92">Zaloguj</button>
</form>
</div>
</main>
</sc-if>
<sc-if value="{{ isApp }}" hint-placeholder-val="{{ true }}">
<nav aria-label="Wtyczki" style="width:80px;flex:none;background:var(--side);border-right:1px solid var(--side-border);color:#ffffff;display:flex;flex-direction:column;align-items:center;padding:14px 0 12px;gap:6px">
<span title="SummerCMS" style="width:40px;height:40px;flex:none;border-radius:12px;background:rgba(252,211,77,.14);display:inline-flex;align-items:center;justify-content:center;margin-bottom:14px"><i class="icon-sun" style="font-size:22px;color:var(--accent)"></i></span>
<sc-for list="{{ rail }}" as="r" hint-placeholder-count="3">
<a href="#" aria-current="{{ r.current }}" aria-label="{{ r.label }}" style="width:68px;display:flex;flex-direction:column;align-items:center;gap:3px;padding:8px 0 6px;border-radius:12px;background:{{ r.bg }};letter-spacing:-0.01em;color:{{ r.color }};font-size:11px;font-weight:{{ r.weight }};line-height:1.2;text-align:center" style-hover="background:rgba(255,255,255,.08);color:#ffffff;text-decoration:none" style-focus="outline:3px solid var(--ring);outline-offset:1px"><i class="icon-{{ r.icon }}" style="font-size:20px"></i><span>{{ r.short }}</span></a>
</sc-for>
<div style="flex:1"></div>
<sc-if value="{{ collapsed }}" hint-placeholder-val="{{ false }}"><button aria-label="Rozwiń menu" title="Rozwiń menu" style="width:40px;height:40px;border:0;border-radius:10px;background:transparent;color:#c3cbda;display:inline-flex;align-items:center;justify-content:center;cursor:pointer" style-hover="background:rgba(255,255,255,.08);color:#fff"><i class="icon-panel-left-open" style="font-size:18px"></i></button></sc-if>
<a href="#" aria-label="Ustawienia" style="width:68px;display:flex;flex-direction:column;align-items:center;gap:3px;padding:8px 0 6px;border-radius:12px;color:#c3cbda;font-size:11px;font-weight:500" style-hover="background:rgba(255,255,255,.08);color:#fff;text-decoration:none"><i class="icon-settings" style="font-size:20px"></i><span>Ustawienia</span></a>
</nav>
<sc-if value="{{ showPanel }}" hint-placeholder-val="{{ true }}">
<aside aria-label="{{ pluginLabel }}" style="width:224px;flex:none;background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;padding:14px 12px;gap:2px">
<div style="display:flex;align-items:center;gap:8px;height:40px;padding:0 4px 0 10px;margin-bottom:14px">
<span style="flex:1;font-weight:700;font-size:16px;letter-spacing:-0.01em">{{ pluginLabel }}</span>
<button aria-label="Zwiń menu" title="Zwiń menu" style="width:30px;height:30px;border:0;border-radius:8px;background:transparent;color:var(--muted);display:inline-flex;align-items:center;justify-content:center;cursor:pointer" style-hover="background:var(--hover);color:var(--text)"><i class="icon-panel-left-close" style="font-size:16px"></i></button>
</div>
<sc-for list="{{ nav }}" as="item" hint-placeholder-count="4">
<a href="#" aria-current="{{ item.current }}" style="display:flex;align-items:center;gap:12px;height:40px;padding:0 10px;border-radius:10px;background:{{ item.bg }};color:{{ item.color }};font-weight:{{ item.weight }}" style-hover="background:var(--hover);text-decoration:none;color:var(--text)"><i class="icon-{{ item.icon }}" style="font-size:18px;width:18px"></i><span>{{ item.label }}</span></a>
</sc-for>
</aside>
</sc-if>
<sc-if value="{{ flyout }}" hint-placeholder-val="{{ false }}">
<div role="menu" aria-label="{{ pluginLabel }}" style="position:absolute;left:88px;top:62px;width:230px;background:var(--surface);border:1px solid var(--border);border-radius:14px;box-shadow:0 16px 40px rgba(20,27,45,.18);padding:8px;z-index:25;display:flex;flex-direction:column;gap:2px">
<div style="padding:6px 10px 8px;font-weight:700">{{ pluginLabel }}</div>
<sc-for list="{{ nav }}" as="item" hint-placeholder-count="4">
<a href="#" role="menuitem" aria-current="{{ item.current }}" style="display:flex;align-items:center;gap:12px;height:38px;padding:0 10px;border-radius:9px;background:{{ item.bg }};color:{{ item.color }};font-weight:{{ item.weight }}" style-hover="background:var(--hover);text-decoration:none;color:var(--text)"><i class="icon-{{ item.icon }}" style="font-size:17px;width:17px"></i><span>{{ item.label }}</span></a>
</sc-for>
</div>
</sc-if>
<div style="flex:1;min-width:0;display:flex;flex-direction:column">
<header style="height:64px;flex:none;display:flex;align-items:center;gap:16px;padding:0 24px 0 32px;background:var(--surface);border-bottom:1px solid var(--border)">
<nav aria-label="Ścieżka" style="display:flex;align-items:center;gap:8px;color:var(--muted)">
<a href="#">{{ pluginLabel }}</a><i class="icon-chevron-right" style="font-size:14px"></i>
<sc-for list="{{ crumbs }}" as="c" hint-placeholder-count="1"><span style="display:flex;align-items:center;gap:8px"><a href="#" style="color:{{ c.color }};font-weight:{{ c.weight }}">{{ c.label }}</a><sc-if value="{{ c.more }}" hint-placeholder-val="{{ false }}"><i class="icon-chevron-right" style="font-size:14px"></i></sc-if></span></sc-for>
</nav>
<div style="flex:1"></div>
<button aria-haspopup="menu" aria-expanded="{{ userMenuStr }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px 0 4px;border:1px solid {{ userBtnBorder }};border-radius:12px;background:var(--surface);color:var(--text);font:inherit;cursor:pointer;text-align:left" style-hover="background:var(--hover)">
<span style="width:34px;height:34px;border-radius:50%;background:var(--accent);color:var(--on-accent);display:inline-flex;align-items:center;justify-content:center;font-size:13px;font-weight:700">TW</span>
<sc-if value="{{ wide }}" hint-placeholder-val="{{ true }}"><span style="display:flex;flex-direction:column;line-height:1.2"><span style="font-weight:600">Tomasz Wójcik</span><span style="font-size:12px;color:var(--muted)">Administrator</span></span></sc-if>
<i class="icon-chevron-down" style="font-size:16px;color:var(--muted)"></i>
</button>
</header>
<main style="flex:1;min-height:0;display:flex;flex-direction:column">
<sc-if value="{{ isList }}" hint-placeholder-val="{{ true }}">
<div style="flex:1;min-height:0;display:flex;flex-direction:column;gap:20px;padding:28px 32px">
<div style="display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap">
<div style="flex:1;min-width:200px"><h1 style="margin:0;font-size:26px;font-weight:700;letter-spacing:-0.02em">Albumy</h1><p style="margin:2px 0 0;color:var(--muted)">348 pozycji w katalogu</p></div>
<button style="display:inline-flex;align-items:center;gap:8px;height:42px;padding:0 18px;border-radius:10px;border:0;background:var(--primary);color:var(--on-primary);font:inherit;font-weight:600;cursor:pointer" style-hover="opacity:.92" style-focus="outline:3px solid var(--ring);outline-offset:2px"><i class="icon-plus" style="font-size:18px"></i>Nowy album</button>
</div>
<div style="flex:1;min-height:0;display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--border);border-radius:16px;box-shadow:var(--shadow);overflow:hidden">
<div role="toolbar" aria-label="Akcje listy" style="flex:none;display:flex;align-items:center;gap:12px;padding:16px 20px;flex-wrap:wrap">
<div style="position:relative;width:320px;max-width:100%">
<i class="icon-search" style="position:absolute;left:14px;top:12px;font-size:18px;color:var(--muted)"></i>
<input type="search" aria-label="Szukaj albumów" placeholder="Szukaj albumów…" defaultValue="{{ searchValue }}" style="height:42px;width:100%;padding:0 14px 0 42px;border:1px solid var(--border-strong);border-radius:10px;background:var(--subtle);color:var(--text);font:inherit;outline:none" style-focus="border-color:var(--primary);box-shadow:0 0 0 3px var(--ring)">
<sc-if value="{{ searchFocus }}" hint-placeholder-val="{{ false }}"><span aria-hidden="true" style="position:absolute;inset:0;border-radius:10px;border:1px solid var(--primary);box-shadow:0 0 0 3px var(--ring);pointer-events:none"></span></sc-if>
</div>
<div style="flex:1"></div>
<sc-if value="{{ hasSel }}" hint-placeholder-val="{{ false }}"><span style="display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 12px;border-radius:999px;background:var(--sel);font-weight:600"><i class="icon-check" style="font-size:14px"></i>Zaznaczono {{ selCount }}</span><button style="display:inline-flex;align-items:center;gap:8px;height:42px;padding:0 16px;border-radius:10px;border:1px solid var(--danger);background:var(--surface);color:var(--danger);font:inherit;font-weight:600;cursor:pointer" style-hover="background:var(--danger-soft)"><i class="icon-trash-2" style="font-size:17px"></i>Usuń zaznaczone</button></sc-if>
<sc-if value="{{ noSel }}" hint-placeholder-val="{{ true }}"><button disabled="{{ true }}" style="display:inline-flex;align-items:center;gap:8px;height:42px;padding:0 16px;border-radius:10px;border:1px solid var(--border);background:var(--surface);color:var(--muted);font:inherit;font-weight:600;opacity:.6;cursor:not-allowed"><i class="icon-trash-2" style="font-size:17px"></i>Usuń zaznaczone</button></sc-if>
</div>
<div style="flex:1;min-height:0;overflow:auto">
<table style="width:100%;min-width:920px;border-collapse:collapse">
<thead>
<tr>
<th style="width:52px;height:44px;padding:0 0 0 20px;text-align:left;background:var(--subtle);border-top:1px solid var(--border);border-bottom:1px solid var(--border);position:sticky;top:0">
<sc-if value="{{ hdrNone }}" hint-placeholder-val="{{ true }}"><span role="checkbox" aria-checked="false" aria-label="Zaznacz wszystkie" style="width:18px;height:18px;border:1.5px solid var(--border-strong);border-radius:5px;display:flex;background:var(--surface)"></span></sc-if>
<sc-if value="{{ hdrIndet }}" hint-placeholder-val="{{ false }}"><span role="checkbox" aria-checked="mixed" aria-label="Zaznacz wszystkie" style="width:18px;height:18px;border-radius:5px;display:flex;align-items:center;justify-content:center;background:var(--primary);color:var(--on-primary)"><i class="icon-minus" style="font-size:13px"></i></span></sc-if>
</th>
<th aria-sort="ascending" style="height:44px;padding:0 14px;text-align:left;font-size:12px;font-weight:600;color:var(--text);background:var(--subtle);border-top:1px solid var(--border);border-bottom:1px solid var(--border);position:sticky;top:0;white-space:nowrap"><span style="display:inline-flex;align-items:center;gap:4px">Nazwa<i class="icon-arrow-up" style="font-size:14px"></i></span></th>
<th style="height:44px;padding:0 14px;text-align:left;font-size:12px;font-weight:600;color:var(--muted);background:var(--subtle);border-top:1px solid var(--border);border-bottom:1px solid var(--border);position:sticky;top:0">Artyści</th>
<th style="height:44px;padding:0 14px;text-align:left;font-size:12px;font-weight:600;color:var(--muted);background:var(--subtle);border-top:1px solid var(--border);border-bottom:1px solid var(--border);position:sticky;top:0;width:110px">Format</th>
<th style="height:44px;padding:0 14px;text-align:left;font-size:12px;font-weight:600;color:var(--muted);background:var(--subtle);border-top:1px solid var(--border);border-bottom:1px solid var(--border);position:sticky;top:0">Gatunek</th>
<th style="height:44px;padding:0 14px;text-align:left;font-size:12px;font-weight:600;color:var(--muted);background:var(--subtle);border-top:1px solid var(--border);border-bottom:1px solid var(--border);position:sticky;top:0;width:72px">Półka</th>
<th style="height:44px;padding:0 14px;text-align:left;font-size:12px;font-weight:600;color:var(--muted);background:var(--subtle);border-top:1px solid var(--border);border-bottom:1px solid var(--border);position:sticky;top:0;width:150px">Utworzono</th>
<th style="height:44px;padding:0 20px 0 14px;text-align:left;font-size:12px;font-weight:600;color:var(--muted);background:var(--subtle);border-top:1px solid var(--border);border-bottom:1px solid var(--border);position:sticky;top:0;width:128px">Wypożyczony</th>
</tr>
</thead>
<tbody>
<sc-for list="{{ rows }}" as="row" hint-placeholder-count="12">
<tr style="background:{{ row.bg }}">
<td style="height:54px;padding:0 0 0 20px;border-bottom:1px solid var(--border)">
<sc-if value="{{ row.sel }}" hint-placeholder-val="{{ false }}"><span role="checkbox" aria-checked="true" style="width:18px;height:18px;border-radius:5px;display:flex;align-items:center;justify-content:center;background:var(--primary);color:var(--on-primary)"><i class="icon-check" style="font-size:13px"></i></span></sc-if>
<sc-if value="{{ row.notSel }}" hint-placeholder-val="{{ true }}"><span role="checkbox" aria-checked="false" style="width:18px;height:18px;border:1.5px solid var(--border-strong);border-radius:5px;display:flex;background:var(--surface)"></span></sc-if>
</td>
<td style="padding:0 14px;border-bottom:1px solid var(--border);font-weight:600;white-space:nowrap"><a href="#">{{ row.name }}</a></td>
<td style="padding:0 14px;border-bottom:1px solid var(--border);color:var(--muted)">{{ row.artists }}</td>
<td style="padding:0 14px;border-bottom:1px solid var(--border)"><span style="display:inline-flex;align-items:center;gap:6px;height:26px;padding:0 10px;border-radius:999px;background:var(--subtle);font-size:13px;font-weight:500;white-space:nowrap"><i class="icon-{{ row.icon }}" style="font-size:14px;color:var(--muted)"></i>{{ row.format }}</span></td>
<td style="padding:0 14px;border-bottom:1px solid var(--border);color:{{ row.genreColor }}">{{ row.genre }}</td>
<td style="padding:0 14px;border-bottom:1px solid var(--border);font-family:'DM Mono',monospace;font-size:13px">{{ row.shelf }}</td>
<td style="padding:0 14px;border-bottom:1px solid var(--border);color:var(--muted);font-size:13px;white-space:nowrap;font-variant-numeric:tabular-nums">{{ row.created }}</td>
<td style="padding:0 20px 0 14px;border-bottom:1px solid var(--border)">
<sc-if value="{{ row.rented }}" hint-placeholder-val="{{ false }}"><span style="display:inline-flex;align-items:center;gap:6px;height:26px;padding:0 10px;border-radius:999px;background:var(--ok-bg);color:var(--ok-text);font-size:13px;font-weight:600"><i class="icon-arrow-up-right" style="font-size:13px"></i>Tak</span></sc-if>
<sc-if value="{{ row.notRented }}" hint-placeholder-val="{{ true }}"><span style="display:inline-flex;align-items:center;height:26px;padding:0 10px;border-radius:999px;color:var(--muted);font-size:13px;font-weight:500;border:1px solid var(--border)">Nie</span></sc-if>
</td>
</tr>
</sc-for>
<sc-for list="{{ skel }}" as="s" hint-placeholder-count="0">
<tr>
<td style="height:54px;padding:0 0 0 20px;border-bottom:1px solid var(--border)"><span style="display:block;width:18px;height:18px;border-radius:5px;background:var(--skel)"></span></td>
<td style="padding:0 14px;border-bottom:1px solid var(--border)"><span style="display:block;height:12px;border-radius:6px;background:var(--skel);width:{{ s.a }}"></span></td>
<td style="padding:0 14px;border-bottom:1px solid var(--border)"><span style="display:block;height:12px;border-radius:6px;background:var(--skel);width:{{ s.b }}"></span></td>
<td style="padding:0 14px;border-bottom:1px solid var(--border)"><span style="display:block;height:26px;border-radius:999px;background:var(--skel);width:70px"></span></td>
<td style="padding:0 14px;border-bottom:1px solid var(--border)"><span style="display:block;height:12px;border-radius:6px;background:var(--skel);width:{{ s.c }}"></span></td>
<td style="padding:0 14px;border-bottom:1px solid var(--border)"><span style="display:block;height:12px;border-radius:6px;background:var(--skel);width:36px"></span></td>
<td style="padding:0 14px;border-bottom:1px solid var(--border)"><span style="display:block;height:12px;border-radius:6px;background:var(--skel);width:110px"></span></td>
<td style="padding:0 20px 0 14px;border-bottom:1px solid var(--border)"><span style="display:block;height:26px;border-radius:999px;background:var(--skel);width:52px"></span></td>
</tr>
</sc-for>
</tbody>
</table>
<sc-if value="{{ isEmpty }}" hint-placeholder-val="{{ false }}">
<div style="display:flex;flex-direction:column;align-items:center;gap:10px;padding:80px 24px;text-align:center">
<span style="width:56px;height:56px;border-radius:50%;background:var(--subtle);display:inline-flex;align-items:center;justify-content:center;color:var(--muted)"><i class="icon-search-x" style="font-size:24px"></i></span>
<div style="font-size:17px;font-weight:700;margin-top:4px">Nic nie znaleziono</div>
<p style="margin:0;color:var(--muted);max-width:360px;text-wrap:pretty">Żaden album nie pasuje do frazy „kaseta 1983”. Spróbuj innej nazwy lub artysty.</p>
<button style="margin-top:8px;height:40px;padding:0 16px;border-radius:10px;border:1px solid var(--border-strong);background:var(--surface);color:var(--text);font:inherit;font-weight:600;cursor:pointer" style-hover="background:var(--hover)">Wyczyść wyszukiwanie</button>
</div>
</sc-if>
</div>
<div style="flex:none;display:flex;align-items:center;gap:16px;padding:14px 20px;border-top:1px solid var(--border);color:var(--muted);font-size:13px;flex-wrap:wrap">
<span aria-live="polite">{{ range }}</span>
<sc-if value="{{ showPager }}" hint-placeholder-val="{{ true }}">
<div style="display:flex;align-items:center;gap:8px"><span>Na stronę</span><button aria-haspopup="listbox" style="display:inline-flex;align-items:center;gap:6px;height:34px;padding:0 10px;border:1px solid var(--border-strong);border-radius:8px;background:var(--surface);color:var(--text);font:inherit;cursor:pointer">12<i class="icon-chevron-down" style="font-size:14px;color:var(--muted)"></i></button></div>
<div style="flex:1"></div>
<nav aria-label="Stronicowanie" style="display:flex;align-items:center;gap:4px">
<button disabled="{{ true }}" style="display:inline-flex;align-items:center;gap:4px;height:34px;padding:0 10px;border:0;border-radius:8px;background:transparent;color:var(--muted);font:inherit;opacity:.5"><i class="icon-arrow-left" style="font-size:15px"></i>Poprzednia</button>
<button aria-current="page" style="width:34px;height:34px;border:0;border-radius:8px;background:var(--sel);color:var(--text);font:inherit;font-weight:700">1</button>
<button style="width:34px;height:34px;border:0;border-radius:8px;background:transparent;color:var(--text);font:inherit;cursor:pointer" style-hover="background:var(--hover)">2</button>
<button style="width:34px;height:34px;border:0;border-radius:8px;background:transparent;color:var(--text);font:inherit;cursor:pointer" style-hover="background:var(--hover)">3</button>
<span style="width:24px;text-align:center">…</span>
<button style="width:34px;height:34px;border:0;border-radius:8px;background:transparent;color:var(--text);font:inherit;cursor:pointer" style-hover="background:var(--hover)">29</button>
<button style="display:inline-flex;align-items:center;gap:4px;height:34px;padding:0 10px;border:0;border-radius:8px;background:transparent;color:var(--text);font:inherit;font-weight:500;cursor:pointer" style-hover="background:var(--hover)">Następna<i class="icon-arrow-right" style="font-size:15px"></i></button>
</nav>
</sc-if>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ isUserList }}" hint-placeholder-val="{{ false }}">
<div style="flex:1;min-height:0;display:flex;flex-direction:column;gap:20px;padding:28px 32px">
<div style="display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap">
<div style="flex:1;min-width:200px"><h1 style="margin:0;font-size:26px;font-weight:700;letter-spacing:-0.02em">Użytkownicy</h1><p style="margin:2px 0 0;color:var(--muted)">42 konta administracyjne</p></div>
<button style="display:inline-flex;align-items:center;gap:8px;height:42px;padding:0 18px;border-radius:10px;border:0;background:var(--primary);color:var(--on-primary);font:inherit;font-weight:600;cursor:pointer" style-hover="opacity:.92"><i class="icon-user-plus" style="font-size:18px"></i>Nowy użytkownik</button>
</div>
<div style="flex:1;min-height:0;display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--border);border-radius:16px;box-shadow:var(--shadow);overflow:hidden">
<div role="toolbar" style="flex:none;display:flex;align-items:center;gap:12px;padding:16px 20px;flex-wrap:wrap">
<div style="position:relative;width:320px;max-width:100%"><i class="icon-search" style="position:absolute;left:14px;top:12px;font-size:18px;color:var(--muted)"></i><input type="search" aria-label="Szukaj użytkowników" placeholder="Szukaj użytkowników…" style="height:42px;width:100%;padding:0 14px 0 42px;border:1px solid var(--border-strong);border-radius:10px;background:var(--subtle);color:var(--text);font:inherit;outline:none"></div>
<div style="flex:1"></div>
<button disabled="{{ true }}" style="display:inline-flex;align-items:center;gap:8px;height:42px;padding:0 16px;border-radius:10px;border:1px solid var(--border);background:var(--surface);color:var(--muted);font:inherit;font-weight:600;opacity:.6;cursor:not-allowed"><i class="icon-trash-2" style="font-size:17px"></i>Usuń zaznaczone</button>
</div>
<div style="flex:1;min-height:0;overflow:auto">
<table style="width:100%;min-width:860px;border-collapse:collapse">
<thead><tr>
<th style="width:52px;height:44px;padding:0 0 0 20px;text-align:left;background:var(--subtle);border-top:1px solid var(--border);border-bottom:1px solid var(--border)"><span role="checkbox" aria-checked="false" aria-label="Zaznacz wszystkie" style="width:18px;height:18px;border:1.5px solid var(--border-strong);border-radius:5px;display:flex;background:var(--surface)"></span></th>
<th aria-sort="ascending" style="padding:0 14px;text-align:left;font-size:12px;font-weight:600;background:var(--subtle);border-top:1px solid var(--border);border-bottom:1px solid var(--border)"><span style="display:inline-flex;align-items:center;gap:4px">Imię i nazwisko<i class="icon-arrow-up" style="font-size:14px"></i></span></th>
<th style="padding:0 14px;text-align:left;font-size:12px;font-weight:600;color:var(--muted);background:var(--subtle);border-top:1px solid var(--border);border-bottom:1px solid var(--border)">E-mail</th>
<th style="padding:0 14px;text-align:left;font-size:12px;font-weight:600;color:var(--muted);background:var(--subtle);border-top:1px solid var(--border);border-bottom:1px solid var(--border)">Grupy</th>
<th style="padding:0 14px;text-align:left;font-size:12px;font-weight:600;color:var(--muted);background:var(--subtle);border-top:1px solid var(--border);border-bottom:1px solid var(--border);width:170px">Ostatnie logowanie</th>
<th style="padding:0 20px 0 14px;text-align:left;font-size:12px;font-weight:600;color:var(--muted);background:var(--subtle);border-top:1px solid var(--border);border-bottom:1px solid var(--border);width:110px">Aktywny</th>
</tr></thead>
<tbody>
<sc-for list="{{ users }}" as="u" hint-placeholder-count="10">
<tr>
<td style="height:54px;padding:0 0 0 20px;border-bottom:1px solid var(--border)"><span role="checkbox" aria-checked="false" style="width:18px;height:18px;border:1.5px solid var(--border-strong);border-radius:5px;display:flex;background:var(--surface)"></span></td>
<td style="padding:0 14px;border-bottom:1px solid var(--border)"><span style="display:flex;align-items:center;gap:10px;font-weight:600"><span style="width:30px;height:30px;border-radius:50%;background:var(--subtle);font-size:11px;display:inline-flex;align-items:center;justify-content:center">{{ u.ini }}</span><a href="#">{{ u.name }}</a></span></td>
<td style="padding:0 14px;border-bottom:1px solid var(--border);color:var(--muted)">{{ u.email }}</td>
<td style="padding:0 14px;border-bottom:1px solid var(--border)">{{ u.groups }}</td>
<td style="padding:0 14px;border-bottom:1px solid var(--border);color:var(--muted);font-size:13px;font-variant-numeric:tabular-nums">{{ u.last }}</td>
<td style="padding:0 20px 0 14px;border-bottom:1px solid var(--border)">
<sc-if value="{{ u.active }}" hint-placeholder-val="{{ true }}"><span style="display:inline-flex;align-items:center;height:26px;padding:0 10px;border-radius:999px;background:var(--ok-bg);color:var(--ok-text);font-size:13px;font-weight:600">Tak</span></sc-if>
<sc-if value="{{ u.inactive }}" hint-placeholder-val="{{ false }}"><span style="display:inline-flex;align-items:center;height:26px;padding:0 10px;border-radius:999px;color:var(--muted);font-size:13px;font-weight:500;border:1px solid var(--border)">Nie</span></sc-if>
</td>
</tr>
</sc-for>
</tbody>
</table>
</div>
<div style="flex:none;display:flex;align-items:center;gap:16px;padding:14px 20px;border-top:1px solid var(--border);color:var(--muted);font-size:13px"><span>1–10 z 42</span><div style="flex:1"></div>
<nav aria-label="Stronicowanie" style="display:flex;align-items:center;gap:4px">
<button aria-current="page" style="width:34px;height:34px;border:0;border-radius:8px;background:var(--sel);color:var(--text);font:inherit;font-weight:700">1</button>
<button style="width:34px;height:34px;border:0;border-radius:8px;background:transparent;color:var(--text);font:inherit">2</button>
<button style="width:34px;height:34px;border:0;border-radius:8px;background:transparent;color:var(--text);font:inherit">3</button>
<button style="width:34px;height:34px;border:0;border-radius:8px;background:transparent;color:var(--text);font:inherit">4</button>
<button style="display:inline-flex;align-items:center;gap:4px;height:34px;padding:0 10px;border:0;border-radius:8px;background:transparent;color:var(--text);font:inherit;font-weight:500">Następna<i class="icon-arrow-right" style="font-size:15px"></i></button>
</nav>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ isFormLike }}" hint-placeholder-val="{{ false }}">
<div style="flex:1;min-height:0;display:flex;flex-direction:column">
<div style="flex:1;min-height:0;overflow:auto;padding:28px 32px 32px">
<div style="max-width:980px;display:flex;flex-direction:column;gap:20px">
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap">
<a href="#" aria-label="Wróć do listy" style="width:40px;height:40px;border-radius:10px;border:1px solid var(--border);background:var(--surface);display:inline-flex;align-items:center;justify-content:center;color:var(--text)"><i class="icon-arrow-left" style="font-size:18px"></i></a>
<div style="flex:1"><h1 style="margin:0;font-size:24px;font-weight:700;letter-spacing:-0.02em">{{ recordName }}</h1><p style="margin:0;color:var(--muted);font-size:13px">{{ formTitle }}</p></div>
<div role="tablist" style="display:flex;gap:4px;padding:4px;border-radius:12px;background:var(--subtle);border:1px solid var(--border)">
<sc-for list="{{ tabs }}" as="tab" hint-placeholder-count="2">
<button role="tab" aria-selected="{{ tab.selected }}" style="height:34px;padding:0 16px;border:0;border-radius:9px;background:{{ tab.bg }};color:{{ tab.color }};box-shadow:{{ tab.shadow }};font:inherit;font-weight:600;cursor:pointer" style-focus="outline:3px solid var(--ring)">{{ tab.label }}</button>
</sc-for>
</div>
</div>
<sc-if value="{{ errors }}" hint-placeholder-val="{{ false }}">
<div role="alert" style="display:flex;gap:12px;align-items:center;padding:14px 18px;border-radius:12px;background:var(--danger-soft);color:var(--danger)"><i class="icon-circle-alert" style="font-size:20px"></i><div><span style="font-weight:700">Nie udało się zapisać.</span> Popraw 2 pola oznaczone poniżej.</div></div>
</sc-if>
<div style="background:var(--surface);border:1px solid var(--border);border-radius:16px;box-shadow:var(--shadow);padding:28px">
<sc-if value="{{ showBasic }}" hint-placeholder-val="{{ false }}">
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:22px 24px">
<div style="display:flex;flex-direction:column;gap:8px">
<label for="c-name" style="font-weight:600">Nazwa <span aria-hidden="true" style="color:var(--danger)">*</span></label>
<input id="c-name" aria-invalid="{{ errorsStr }}" defaultValue="{{ nameVal }}" style="height:44px;padding:0 14px;border:1px solid {{ errBorder }};border-radius:10px;background:var(--surface);color:var(--text);font:inherit;outline:none;width:100%" style-focus="border-color:var(--primary);box-shadow:0 0 0 3px var(--ring)">
<sc-if value="{{ errors }}" hint-placeholder-val="{{ false }}"><p style="margin:0;display:flex;gap:6px;align-items:center;font-size:13px;color:var(--danger)"><i class="icon-circle-alert" style="font-size:14px"></i>Pole Nazwa jest wymagane.</p></sc-if>
</div>
<div style="display:flex;flex-direction:column;gap:8px">
<label style="font-weight:600">Artyści <span aria-hidden="true" style="color:var(--danger)">*</span></label>
<div style="min-height:44px;display:flex;flex-wrap:wrap;align-items:center;gap:6px;padding:6px 12px 6px 6px;border:1px solid var(--border-strong);border-radius:10px;background:var(--surface)">
<span style="display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 6px 0 4px;border-radius:999px;background:var(--subtle);font-size:13px;font-weight:600"><span style="width:22px;height:22px;border-radius:50%;background:var(--primary);color:var(--on-primary);font-size:10px;display:inline-flex;align-items:center;justify-content:center">CN</span>Czesław Niemen<button aria-label="Usuń: Czesław Niemen" style="width:20px;height:20px;border:0;border-radius:50%;background:transparent;color:var(--muted);display:inline-flex;align-items:center;justify-content:center;cursor:pointer" style-hover="background:var(--border)"><i class="icon-x" style="font-size:12px"></i></button></span>
<span style="display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 6px 0 4px;border-radius:999px;background:var(--subtle);font-size:13px;font-weight:600"><span style="width:22px;height:22px;border-radius:50%;background:var(--primary);color:var(--on-primary);font-size:10px;display:inline-flex;align-items:center;justify-content:center">AK</span>Akwarium<button aria-label="Usuń: Akwarium" style="width:20px;height:20px;border:0;border-radius:50%;background:transparent;color:var(--muted);display:inline-flex;align-items:center;justify-content:center;cursor:pointer" style-hover="background:var(--border)"><i class="icon-x" style="font-size:12px"></i></button></span>
<input aria-label="Szukaj artystów" placeholder="Dodaj artystę…" style="flex:1;min-width:100px;height:30px;border:0;outline:none;background:transparent;color:var(--text);font:inherit">
</div>
</div>
<div style="display:flex;flex-direction:column;gap:8px">
<label style="font-weight:600">Format <span aria-hidden="true" style="color:var(--danger)">*</span></label>
<button aria-haspopup="listbox" style="height:44px;display:flex;align-items:center;justify-content:space-between;padding:0 12px 0 14px;border:1px solid var(--border-strong);border-radius:10px;background:var(--surface);color:var(--text);font:inherit;text-align:left"><span style="display:flex;align-items:center;gap:8px"><i class="icon-disc-3" style="font-size:16px;color:var(--muted)"></i>Winyl</span><i class="icon-chevron-down" style="font-size:18px;color:var(--muted)"></i></button>
</div>
<div style="display:flex;flex-direction:column;gap:8px">
<label style="font-weight:600">Gatunek</label>
<button aria-haspopup="listbox" style="height:44px;display:flex;align-items:center;justify-content:space-between;padding:0 12px 0 14px;border:1px solid var(--border-strong);border-radius:10px;background:var(--surface);color:{{ genreColor }};font:inherit;text-align:left"><span>{{ genreVal }}</span><i class="icon-chevron-down" style="font-size:18px;color:var(--muted)"></i></button>
</div>
<div style="display:flex;flex-direction:column;gap:8px">
<label for="c-shelf" style="font-weight:600">Półka</label>
<input id="c-shelf" aria-invalid="{{ errorsStr }}" defaultValue="{{ shelfVal }}" style="height:44px;padding:0 14px;border:1px solid {{ errBorder }};border-radius:10px;background:var(--surface);color:var(--text);font:inherit;outline:none;width:100%">
<sc-if value="{{ errors }}" hint-placeholder-val="{{ false }}"><p style="margin:0;display:flex;gap:6px;align-items:center;font-size:13px;color:var(--danger)"><i class="icon-circle-alert" style="font-size:14px"></i>Pole Półka może mieć maksymalnie 12 znaków.</p></sc-if>
</div>
<div></div>
<div style="display:flex;flex-direction:column;gap:8px;grid-column:1 / -1">
<label for="c-desc" style="font-weight:600">Opis</label>
<textarea id="c-desc" rows="4" defaultValue="Nagrany z zespołem Akwarium w 1969 r. Wydanie Polskie Nagrania Muza XL 0569. Stan płyty bardzo dobry, okładka lekko przetarta na grzbiecie." style="padding:12px 14px;border:1px solid var(--border-strong);border-radius:10px;background:var(--surface);color:var(--text);font:inherit;outline:none;width:100%;resize:vertical"></textarea>
</div>
<div style="display:flex;align-items:center;gap:14px;padding:14px 16px;border:1px solid var(--border);border-radius:12px">
<div style="flex:1"><div style="font-weight:600">Wypożyczony</div><div style="font-size:13px;color:var(--muted)">Płyta jest aktualnie poza domem</div></div>
<button role="switch" aria-checked="false" aria-label="Wypożyczony" style="width:44px;height:26px;border-radius:999px;border:0;padding:3px;background:var(--border-strong);display:flex;cursor:pointer"><span style="width:20px;height:20px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.2)"></span></button>
</div>
<label style="display:flex;align-items:center;gap:14px;padding:14px 16px;border:1px solid var(--border);border-radius:12px;cursor:pointer">
<div style="flex:1"><div style="font-weight:600">Ulubiony</div><div style="font-size:13px;color:var(--muted)">Pokazuj na początku listy</div></div>
<span role="checkbox" aria-checked="true" style="width:20px;height:20px;border-radius:6px;display:flex;align-items:center;justify-content:center;background:var(--primary);color:var(--on-primary)"><i class="icon-check" style="font-size:14px"></i></span>
</label>
</div>
</sc-if>
<sc-if value="{{ showDetails }}" hint-placeholder-val="{{ false }}">
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:22px 24px">
<div style="display:flex;flex-direction:column;gap:8px"><label style="font-weight:600">Rok wydania</label><input defaultValue="1970" style="height:44px;padding:0 14px;border:1px solid var(--border-strong);border-radius:10px;background:var(--surface);color:var(--text);font:inherit;outline:none;width:100%"></div>
<div style="display:flex;flex-direction:column;gap:8px"><label style="font-weight:600">Wytwórnia</label><input defaultValue="Polskie Nagrania Muza" style="height:44px;padding:0 14px;border:1px solid var(--border-strong);border-radius:10px;background:var(--surface);color:var(--text);font:inherit;outline:none;width:100%"></div>
<div style="display:flex;flex-direction:column;gap:8px"><label style="font-weight:600">Numer katalogowy</label><input defaultValue="XL 0569" style="height:44px;padding:0 14px;border:1px solid var(--border-strong);border-radius:10px;background:var(--surface);color:var(--text);font:inherit;outline:none;width:100%"></div>
<div style="display:flex;flex-direction:column;gap:8px"><label style="font-weight:600">Stan nośnika</label><button aria-haspopup="listbox" style="height:44px;display:flex;align-items:center;justify-content:space-between;padding:0 12px 0 14px;border:1px solid var(--border-strong);border-radius:10px;background:var(--surface);color:var(--text);font:inherit;text-align:left"><span>Bardzo dobry (VG+)</span><i class="icon-chevron-down" style="font-size:18px;color:var(--muted)"></i></button></div>
<div style="display:flex;flex-direction:column;gap:8px">
<span style="font-weight:600">Kolor okładki</span>
<div role="note" style="min-height:44px;display:flex;align-items:center;gap:10px;padding:10px 14px;border:1.5px dashed var(--border-strong);border-radius:10px;background:var(--subtle);color:var(--muted);font-size:13px"><i class="icon-puzzle" style="font-size:16px"></i><span>Nieobsługiwany typ pola: <code style="font-family:'DM Mono',monospace;color:var(--text)">colorpicker</code></span></div>
</div>
</div>
</sc-if>
<sc-if value="{{ isRel }}" hint-placeholder-val="{{ false }}">
<section aria-labelledby="c-rel-h" style="display:flex;flex-direction:column;gap:16px">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
<div style="flex:1;min-width:200px"><h2 id="c-rel-h" style="margin:0;font-size:17px;font-weight:700">Redaktorzy</h2><p style="margin:0;color:var(--muted);font-size:13px">Osoby, które mogą edytować tę kolekcję</p></div>
<div style="position:relative;width:220px"><i class="icon-search" style="position:absolute;left:12px;top:11px;font-size:16px;color:var(--muted)"></i><input type="search" aria-label="Szukaj redaktorów" placeholder="Szukaj…" style="height:38px;width:100%;padding:0 12px 0 36px;border:1px solid var(--border-strong);border-radius:10px;background:var(--subtle);color:var(--text);font:inherit;outline:none"></div>
<button style="display:inline-flex;align-items:center;gap:6px;height:38px;padding:0 14px;border-radius:10px;border:1px solid var(--danger);background:var(--surface);color:var(--danger);font:inherit;font-weight:600;cursor:pointer"><i class="icon-user-minus" style="font-size:16px"></i>Usuń zaznaczonych</button>
<button style="display:inline-flex;align-items:center;gap:6px;height:38px;padding:0 14px;border-radius:10px;border:0;background:var(--primary);color:var(--on-primary);font:inherit;font-weight:600;cursor:pointer"><i class="icon-user-plus" style="font-size:16px"></i>Dodaj redaktora</button>
</div>
<div style="border:1px solid var(--border);border-radius:12px;overflow:hidden">
<table style="width:100%;border-collapse:collapse">
<thead><tr>
<th style="width:52px;height:42px;padding:0 0 0 18px;text-align:left;background:var(--subtle);border-bottom:1px solid var(--border)"><span role="checkbox" aria-checked="mixed" style="width:18px;height:18px;border-radius:5px;display:flex;align-items:center;justify-content:center;background:var(--primary);color:var(--on-primary)"><i class="icon-minus" style="font-size:13px"></i></span></th>
<th style="padding:0 14px;text-align:left;font-size:12px;font-weight:600;color:var(--muted);background:var(--subtle);border-bottom:1px solid var(--border)">Imię i nazwisko</th>
<th style="padding:0 14px;text-align:left;font-size:12px;font-weight:600;color:var(--muted);background:var(--subtle);border-bottom:1px solid var(--border)">E-mail</th>
<th style="padding:0 18px 0 14px;text-align:left;font-size:12px;font-weight:600;color:var(--muted);background:var(--subtle);border-bottom:1px solid var(--border);width:160px">Dodano</th>
</tr></thead>
<tbody>
<sc-for list="{{ editors }}" as="e" hint-placeholder-count="3">
<tr style="background:{{ e.bg }}">
<td style="height:56px;padding:0 0 0 18px;border-bottom:1px solid var(--border)">
<sc-if value="{{ e.sel }}" hint-placeholder-val="{{ false }}"><span role="checkbox" aria-checked="true" style="width:18px;height:18px;border-radius:5px;display:flex;align-items:center;justify-content:center;background:var(--primary);color:var(--on-primary)"><i class="icon-check" style="font-size:13px"></i></span></sc-if>
<sc-if value="{{ e.notSel }}" hint-placeholder-val="{{ true }}"><span role="checkbox" aria-checked="false" style="width:18px;height:18px;border:1.5px solid var(--border-strong);border-radius:5px;display:flex;background:var(--surface)"></span></sc-if>
</td>
<td style="padding:0 14px;border-bottom:1px solid var(--border)"><span style="display:flex;align-items:center;gap:10px;font-weight:600"><span style="width:30px;height:30px;border-radius:50%;background:var(--subtle);font-size:11px;display:inline-flex;align-items:center;justify-content:center">{{ e.ini }}</span>{{ e.name }}</span></td>
<td style="padding:0 14px;border-bottom:1px solid var(--border);color:var(--muted)">{{ e.email }}</td>
<td style="padding:0 18px 0 14px;border-bottom:1px solid var(--border);color:var(--muted);font-size:13px">{{ e.added }}</td>
</tr>
</sc-for>
</tbody>
</table>
</div>
</section>
</sc-if>
</div>
</div>
</div>
<div style="flex:none;display:flex;align-items:center;gap:10px;padding:14px 32px;border-top:1px solid var(--border);background:var(--surface);flex-wrap:wrap">
<button style="display:inline-flex;align-items:center;gap:6px;height:42px;padding:0 16px;border-radius:10px;border:1px solid var(--danger);background:transparent;color:var(--danger);font:inherit;font-weight:600;cursor:pointer" style-hover="background:var(--danger-soft)"><i class="icon-trash-2" style="font-size:17px"></i>Usuń</button>
<div style="flex:1"></div>
<button style="height:42px;padding:0 16px;border-radius:10px;border:0;background:transparent;color:var(--muted);font:inherit;font-weight:600;cursor:pointer" style-hover="background:var(--hover);color:var(--text)">Anuluj</button>
<button style="height:42px;padding:0 18px;border-radius:10px;border:1px solid var(--border-strong);background:var(--surface);color:var(--text);font:inherit;font-weight:600;cursor:pointer" style-hover="background:var(--hover)">Zapisz i zamknij</button>
<button style="height:42px;padding:0 22px;border-radius:10px;border:0;background:var(--primary);color:var(--on-primary);font:inherit;font-weight:600;cursor:pointer" style-hover="opacity:.92" style-focus="outline:3px solid var(--ring);outline-offset:2px">Zapisz</button>
</div>
</div>
</sc-if>
</main>
</div>
<sc-if value="{{ userMenu }}" hint-placeholder-val="{{ false }}">
<div role="menu" style="position:absolute;top:60px;right:24px;width:280px;background:var(--surface);border:1px solid var(--border);border-radius:14px;box-shadow:0 16px 40px rgba(20,27,45,.16);padding:8px;z-index:20">
<div style="display:flex;gap:12px;align-items:center;padding:10px"><span style="width:40px;height:40px;border-radius:50%;background:var(--accent);color:var(--on-accent);display:inline-flex;align-items:center;justify-content:center;font-weight:700">TW</span><div><div style="font-weight:700">Tomasz Wójcik</div><div style="font-size:13px;color:var(--muted)">tomasz.wojcik@plytarium.pl</div></div></div>
<div style="height:1px;background:var(--border);margin:6px 0"></div>
<button role="menuitem" style="width:100%;display:flex;align-items:center;gap:10px;height:40px;padding:0 10px;border:0;border-radius:9px;background:var(--hover);color:var(--text);font:inherit;font-weight:500;text-align:left;cursor:pointer;outline:3px solid var(--ring)"><i class="icon-log-out" style="font-size:18px;color:var(--muted)"></i>Wyloguj</button>
</div>
</sc-if>
<sc-if value="{{ toast }}" hint-placeholder-val="{{ false }}">
<div role="status" style="position:absolute;left:50%;bottom:88px;transform:translateX(-50%);display:flex;gap:12px;align-items:center;padding:12px 12px 12px 16px;background:#1d2740;color:#ffffff;border-radius:14px;box-shadow:0 16px 40px rgba(0,0,0,.25);z-index:20">
<span style="width:26px;height:26px;border-radius:50%;background:var(--accent);color:var(--on-accent);display:inline-flex;align-items:center;justify-content:center"><i class="icon-check" style="font-size:15px"></i></span>
<span style="font-weight:500">Album „Enigmatic” został zapisany</span>
<button aria-label="Zamknij" style="width:28px;height:28px;border:0;border-radius:8px;background:transparent;color:#c3cbda;display:inline-flex;align-items:center;justify-content:center;cursor:pointer" style-hover="background:rgba(255,255,255,.1)"><i class="icon-x" style="font-size:15px"></i></button>
</div>
</sc-if>
<sc-if value="{{ modal }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute;inset:0;background:var(--overlay);display:flex;align-items:center;justify-content:center;padding:24px;z-index:30">
<div role="dialog" aria-modal="true" aria-labelledby="c-dlg" style="width:560px;max-width:100%;background:var(--surface);border-radius:20px;box-shadow:0 24px 60px rgba(0,0,0,.3);display:flex;flex-direction:column;padding:24px;gap:16px">
<div style="display:flex;align-items:flex-start;gap:12px">
<div style="flex:1"><h2 id="c-dlg" style="margin:0;font-size:20px;font-weight:700;letter-spacing:-0.01em">Dodaj redaktora</h2><p style="margin:4px 0 0;color:var(--muted);font-size:13px">Wybierz osoby spoza obecnych redaktorów. Właściciel kolekcji nie jest wyświetlany.</p></div>
<button aria-label="Zamknij" style="width:34px;height:34px;border:0;border-radius:10px;background:var(--subtle);color:var(--text);display:inline-flex;align-items:center;justify-content:center;cursor:pointer"><i class="icon-x" style="font-size:17px"></i></button>
</div>
<div style="position:relative"><i class="icon-search" style="position:absolute;left:14px;top:12px;font-size:18px;color:var(--muted)"></i><input type="search" aria-label="Szukaj użytkowników" placeholder="Szukaj po nazwisku lub e-mailu…" style="height:44px;width:100%;padding:0 14px 0 42px;border:1px solid var(--primary);border-radius:10px;background:var(--surface);color:var(--text);font:inherit;outline:none;box-shadow:0 0 0 3px var(--ring)"></div>
<div role="listbox" aria-multiselectable="true" style="display:flex;flex-direction:column;gap:6px">
<sc-for list="{{ cands }}" as="c" hint-placeholder-count="5">
<div role="option" aria-selected="{{ c.selStr }}" style="display:flex;align-items:center;gap:12px;height:54px;padding:0 14px;border-radius:12px;border:1px solid {{ c.border }};background:{{ c.bg }};cursor:pointer">
<span style="width:32px;height:32px;border-radius:50%;background:var(--subtle);font-size:12px;font-weight:600;display:inline-flex;align-items:center;justify-content:center">{{ c.ini }}</span>
<div style="flex:1;line-height:1.3"><div style="font-weight:600">{{ c.name }}</div><div style="font-size:13px;color:var(--muted)">{{ c.email }}</div></div>
<sc-if value="{{ c.sel }}" hint-placeholder-val="{{ false }}"><span style="width:20px;height:20px;border-radius:6px;display:flex;align-items:center;justify-content:center;background:var(--primary);color:var(--on-primary)"><i class="icon-check" style="font-size:14px"></i></span></sc-if>
<sc-if value="{{ c.notSel }}" hint-placeholder-val="{{ true }}"><span style="width:20px;height:20px;border:1.5px solid var(--border-strong);border-radius:6px"></span></sc-if>
</div>
</sc-for>
</div>
<div style="display:flex;align-items:center;gap:8px;color:var(--muted);font-size:13px">
<span>1–5 z 14</span><div style="flex:1"></div>
<button disabled="{{ true }}" aria-label="Poprzednia strona" style="width:34px;height:34px;border:1px solid var(--border);border-radius:9px;background:var(--surface);color:var(--muted);opacity:.5;display:inline-flex;align-items:center;justify-content:center"><i class="icon-chevron-left" style="font-size:16px"></i></button>
<button aria-label="Następna strona" style="width:34px;height:34px;border:1px solid var(--border);border-radius:9px;background:var(--surface);color:var(--text);display:inline-flex;align-items:center;justify-content:center;cursor:pointer"><i class="icon-chevron-right" style="font-size:16px"></i></button>
</div>
<div style="display:flex;gap:10px">
<button style="flex:1;height:44px;border-radius:10px;border:1px solid var(--border-strong);background:var(--surface);color:var(--text);font:inherit;font-weight:600;cursor:pointer">Anuluj</button>
<button style="flex:1;height:44px;border-radius:10px;border:0;background:var(--primary);color:var(--on-primary);font:inherit;font-weight:600;cursor:pointer">Dodaj (2)</button>
</div>
</div>
</div>
</sc-if>
</sc-if>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:1280,&quot;height&quot;:900},&quot;plugin&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;plytarium&quot;,&quot;users&quot;],&quot;default&quot;:&quot;plytarium&quot;,&quot;tsType&quot;:&quot;string&quot;},&quot;flyout&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:false,&quot;tsType&quot;:&quot;boolean&quot;},&quot;screen&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;login&quot;,&quot;list&quot;,&quot;form&quot;,&quot;relation&quot;],&quot;default&quot;:&quot;list&quot;,&quot;tsType&quot;:&quot;string&quot;},&quot;listState&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;default&quot;,&quot;selected&quot;,&quot;empty&quot;,&quot;loading&quot;],&quot;default&quot;:&quot;default&quot;,&quot;tsType&quot;:&quot;string&quot;},&quot;tab&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;basic&quot;,&quot;details&quot;],&quot;default&quot;:&quot;basic&quot;,&quot;tsType&quot;:&quot;string&quot;},&quot;dark&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:false,&quot;tsType&quot;:&quot;boolean&quot;},&quot;narrow&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:false,&quot;tsType&quot;:&quot;boolean&quot;},&quot;errors&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:false,&quot;tsType&quot;:&quot;boolean&quot;},&quot;toast&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:false,&quot;tsType&quot;:&quot;boolean&quot;},&quot;modal&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:false,&quot;tsType&quot;:&quot;boolean&quot;},&quot;userMenu&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:false,&quot;tsType&quot;:&quot;boolean&quot;}}">
const ALBUMS=[
['Astigmatic','Krzysztof Komeda','Winyl','Jazz','J-02','2026-09-21 14:32',false],
['Ella and Louis','Ella Fitzgerald, Louis Armstrong','Winyl','Jazz','J-04','2026-09-20 19:05',true],
['Enigmatic','Czesław Niemen, Akwarium','Winyl','Rock progresywny','R-11','2026-09-18 10:47',false],
['Kind of Blue','Miles Davis','Winyl','Jazz','J-01','2026-09-17 21:13',false],
['Legenda','Armia','Kaseta','Punk','K-03','2026-09-15 08:26',false],
['Nevermind','Nirvana','CD','Grunge','C-07','2026-09-14 16:58',true],
['Nowe sytuacje','Republika','Kaseta','Nowa fala','K-01','2026-09-12 12:09',false],
['OK Computer','Radiohead','CD','Rock alternatywny','C-12','2026-09-10 18:40',false],
['Rumours','Fleetwood Mac','Winyl','','R-04','2026-09-08 20:15',true],
['Spokojnie','Kult','Winyl','Rock','R-02','2026-09-05 11:31',false],
['Tutu','Miles Davis','CD','Jazz fusion','C-03','2026-09-03 09:52',false],
['Unknown Pleasures','Joy Division','Winyl','Post-punk','R-09','2026-09-01 22:04',false]];
const ICON={Winyl:'disc-3',CD:'disc',Kaseta:'cassette-tape'};
const EDITORS=[['Anna Kowalska','anna.kowalska@plytarium.pl','2026-08-02 17:20',false],['Marek Nowak','marek.nowak@plytarium.pl','2026-08-14 09:03',true],['Julia Wiśniewska','julia.wisniewska@plytarium.pl','2026-09-19 20:41',false]];
const CANDS=[['Piotr Zieliński','piotr.zielinski@plytarium.pl',true],['Katarzyna Lewandowska','k.lewandowska@plytarium.pl',true],['Michał Kamiński','michal.kaminski@plytarium.pl',false],['Agnieszka Dąbrowska','a.dabrowska@plytarium.pl',false],['Paweł Szymański','pawel.szymanski@plytarium.pl',false]];
const SKEL=[['62%','48%','50%'],['44%','70%','38%'],['70%','40%','60%'],['52%','58%','44%'],['38%','66%','52%'],['66%','44%','36%'],['48%','52%','58%'],['58%','62%','42%']].map(([a,b,c])=>({a,b,c}));
const USERS=[['Anna Kowalska','anna.kowalska@plytarium.pl','Redaktorzy','2026-09-26 08:14',true],['Tomasz Wójcik','tomasz.wojcik@plytarium.pl','Administratorzy','2026-09-26 09:02',true],['Julia Wiśniewska','julia.wisniewska@plytarium.pl','Redaktorzy','2026-09-25 21:40',true],['Paweł Szymański','pawel.szymanski@plytarium.pl','Goście','2026-07-11 18:03',false],['Marek Nowak','marek.nowak@plytarium.pl','Redaktorzy, Moderatorzy','2026-09-24 12:55',true],['Piotr Zieliński','piotr.zielinski@plytarium.pl','Goście','2026-09-02 16:20',true],['Katarzyna Lewandowska','k.lewandowska@plytarium.pl','Redaktorzy','2026-09-20 10:31',true],['Michał Kamiński','michal.kaminski@plytarium.pl','Moderatorzy','2026-08-29 14:12',true],['Agnieszka Dąbrowska','a.dabrowska@plytarium.pl','Goście','2026-06-18 19:47',false],['Ewa Mazur','ewa.mazur@plytarium.pl','Redaktorzy','2026-09-23 07:58',true]].sort((a,b)=>a[0].localeCompare(b[0],'pl'));
const PLUGINS={plytarium:{label:'Płytarium',short:'Płytarium',icon:'disc-3',items:[['Albumy','disc-3'],['Artyści','mic-vocal'],['Kolekcje','library'],['Gatunki','tags']]},users:{label:'Użytkownicy',short:'Użytkownicy',icon:'users',items:[['Użytkownicy','users'],['Grupy','users-round'],['Role','shield-check']]},media:{label:'Media',short:'Media',icon:'image',items:[]}};
const ini=n=>n.split(' ').map(w=>w[0]).join('').slice(0,2);
const LIGHT={bg:'#f4f6f9',surface:'#ffffff',subtle:'#f3f5f8',border:'#e6e9ef',borderStrong:'#d2d8e2',text:'#141b2d',muted:'#566175',placeholder:'#6b7588',primary:'#22304d',onPrimary:'#ffffff',danger:'#c62828',dangerSoft:'#fdf0f0',okBg:'#e3f4e8',okText:'#1c6b35',ring:'rgba(252,196,40,.55)',hover:'#f1f3f7',sel:'#fdf3cf',skel:'#eceff4',overlay:'rgba(20,27,45,.5)',side:'#1d2740',sideBorder:'#1d2740',shadow:'0 1px 2px rgba(20,27,45,.04),0 4px 16px rgba(20,27,45,.05)',scheme:'light'};
const DARK={bg:'#111726',surface:'#182033',subtle:'#1f283d',border:'#29334b',borderStrong:'#3a4661',text:'#eef1f6',muted:'#a9b3c6',placeholder:'#8a95ab',primary:'#fcd34d',onPrimary:'#1b2540',danger:'#f58a8a',dangerSoft:'#3a1d24',okBg:'#173826',okText:'#8fdfa8',ring:'rgba(252,211,77,.45)',hover:'#212b42',sel:'#3a3622',skel:'#263049',overlay:'rgba(5,8,16,.7)',side:'#0d1320',sideBorder:'#222b40',shadow:'none',scheme:'dark'};
class Component extends DCLogic {
renderVals() {
const p=this.props, screen=p.screen??'list', ls=p.listState??'default', narrow=!!p.narrow, tab=p.tab??'basic', errors=!!p.errors;
const t=p.dark?DARK:LIGHT;
const sel=ls==='selected'?[2,5,6]:[];
const showRows=ls==='default'||ls==='selected';
const rows=showRows?ALBUMS.map((a,i)=>{const s=sel.includes(i);return {name:a[0],artists:a[1],format:a[2],icon:ICON[a[2]],genre:a[3]||'—',genreColor:a[3]?'var(--text)':'var(--muted)',shelf:a[4],created:a[5],rented:a[6],notRented:!a[6],sel:s,notSel:!s,bg:s?'var(--sel)':'transparent'};}):[];
const active=screen==='relation'?'Kolekcje':'Albumy';
const plugin=p.plugin??'plytarium', isUsers=plugin==='users'&&screen==='list';
const act=isUsers?'Użytkownicy':active, pl=PLUGINS[isUsers?'users':'plytarium'];
const nav=pl.items.map(([label,icon])=>{const a=label===act;return {label,icon,current:a?'page':undefined,bg:a?'var(--sel)':'transparent',color:a?'var(--text)':'var(--muted)',weight:a?600:500};});
const rail=['plytarium','users','media'].map(k=>{const P=PLUGINS[k],a=P===pl;return {label:P.label,short:P.short,icon:P.icon,current:a?'page':undefined,bg:a?'rgba(252,211,77,.14)':'transparent',color:a?'#fcd34d':'#c3cbda',weight:a?700:500};});
const tabDefs=screen==='relation'?[['Podstawowe',false],['Redaktorzy',true]]:[['Podstawowe',tab==='basic'],['Szczegóły',tab==='details']];
const tabs=tabDefs.map(([label,a])=>({label,selected:a?'true':'false',bg:a?'var(--surface)':'transparent',color:a?'var(--text)':'var(--muted)',shadow:a?'0 1px 3px rgba(20,27,45,.12)':'none'}));
const crumbs=isUsers?[{label:'Użytkownicy',color:'var(--text)',weight:600,more:false}]:screen==='list'?[{label:'Albumy',color:'var(--text)',weight:600,more:false}]:screen==='form'?[{label:'Albumy',color:'var(--muted)',weight:400,more:true},{label:'Enigmatic',color:'var(--text)',weight:600,more:false}]:[{label:'Kolekcje',color:'var(--muted)',weight:400,more:true},{label:'Kolekcja domowa',color:'var(--text)',weight:600,more:false}];
const userMenu=!!p.userMenu;
return {
t, isLogin:screen==='login', isApp:screen!=='login', isList:screen==='list'&&!isUsers, isUserList:isUsers, rail, pluginLabel:pl.label, collapsed:narrow, showPanel:!narrow, flyout:narrow&&!!p.flyout, users:USERS.map(([name,email,groups,last,a])=>({name,email,groups,last,ini:ini(name),active:a,inactive:!a})), isFormLike:screen==='form'||screen==='relation', isRel:screen==='relation',
showBasic:screen==='form'&&tab==='basic', showDetails:screen==='form'&&tab==='details',
wide:!narrow, nav, tabs, crumbs,
rows, skel:ls==='loading'?SKEL:[], isEmpty:ls==='empty',
hasSel:sel.length>0, noSel:sel.length===0, selCount:sel.length, hdrIndet:sel.length>0, hdrNone:sel.length===0,
searchFocus:ls==='default'&&!userMenu, searchValue:ls==='empty'?'kaseta 1983':'',
range:ls==='empty'?'0 wyników':ls==='loading'?'Wczytywanie…':'1–12 z 348', showPager:ls!=='empty',
recordName:screen==='relation'?'Kolekcja domowa':'Enigmatic', formTitle:screen==='relation'?'Edycja kolekcji':'Edycja albumu · Czesław Niemen, Akwarium',
errors, errorsStr:errors?'true':'false', errBorder:errors?'var(--danger)':'var(--border-strong)',
nameVal:errors?'':'Enigmatic', shelfVal:errors?'Regał w salonie, góra':'R-11',
genreVal:errors?'— brak —':'Rock progresywny', genreColor:errors?'var(--muted)':'var(--text)',
editors:EDITORS.map(([name,email,added,s])=>({name,email,added,ini:ini(name),sel:s,notSel:!s,bg:s?'var(--sel)':'transparent'})),
cands:CANDS.map(([name,email,s])=>({name,email,ini:ini(name),sel:s,notSel:!s,selStr:s?'true':'false',bg:s?'var(--sel)':'var(--surface)',border:s?'#e0b020':'var(--border)'})),
userMenu, userMenuStr:userMenu?'true':'false', userBtnBorder:userMenu?'var(--border-strong)':'transparent',
toast:!!p.toast, modal:!!p.modal
};
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,387 @@
# Handoff: SummerCMS Admin Panel — Direction C ("Generic modern dashboard")
## Overview
This is the admin panel for SummerCMS, a CMS framework. The mocks use data from one real app: **Płytarium**, a home catalogue of vinyl, CD and cassette releases. The panel is a **schema-driven Vue 3 SPA**. Every list and form is generated from YAML-defined `columns` and `fields`. Build generic components, not screens: one DataTable, one FormField row, one Tabs component, one RelationManager. The UI language is **Polish**, and all copy below is final.
Screens covered: Login, Navigation shell (multi-plugin: Płytarium + Użytkownicy), List (Albumy) with its selected/empty/loading states, Form (Edycja albumu) with tabs and 422 errors, and the relation manager (Kolekcje → Redaktorzy) with its "Dodaj redaktora" modal. Light and dark mode are both included, plus tablet width.
## About the Design Files
`Direction C v2.dc.html` is a **design reference built in HTML**, not production code. Recreate it in the target Vue 3 codebase using its existing patterns. If the codebase has no UI setup yet, use Vue 3 + Vite + Tailwind with headless primitives (e.g. Radix Vue / Reka UI) and lucide-vue-next icons.
To view it, open `Direction C v2.dc.html` in a browser. `support.js` must be in the same folder, and you need internet access for the fonts and icons. The component's props act as a state switcher:
- `plugin`: `plytarium | users` (use `users` with `screen=list` to see the Users plugin list)
- `flyout`: boolean; with `narrow`, shows the collapsed-rail flyout
- `screen`: `login | list | form | relation`
- `listState`: `default | selected | empty | loading`
- `tab`: `basic | details`
- `dark`, `narrow` (tablet), `errors` (422), `toast`, `modal`, `userMenu`: booleans
All sample data sits in the `ALBUMS`, `EDITORS` and `CANDS` constants in the logic class.
## Fidelity
**High-fidelity.** Colours, type, spacing, radii and copy are final. Match them closely.
---
## Design Tokens
### Colours
| Token | Light | Dark | Use |
|---|---|---|---|
| bg | `#f4f6f9` | `#111726` | App background behind cards |
| surface | `#ffffff` | `#182033` | Cards, header, inputs, footer bar |
| subtle | `#f3f5f8` | `#1f283d` | Table header, search input fill, chips, avatar bg |
| border | `#e6e9ef` | `#29334b` | Card borders, row dividers |
| borderStrong | `#d2d8e2` | `#3a4661` | Input borders, unchecked checkbox, switch off |
| text | `#141b2d` | `#eef1f6` | Primary text |
| muted | `#566175` | `#a9b3c6` | Secondary text, column headers, icons |
| placeholder | `#6b7588` | `#8a95ab` | Input placeholder |
| primary | `#22304d` (navy) | `#fcd34d` (yellow) | Primary buttons, checked checkbox |
| onPrimary | `#ffffff` | `#1b2540` | Text on primary |
| danger | `#c62828` | `#f58a8a` | Destructive, errors |
| dangerSoft | `#fdf0f0` | `#3a1d24` | Error banner bg, destructive hover |
| okBg / okText | `#e3f4e8` / `#1c6b35` | `#173826` / `#8fdfa8` | "Tak" badge |
| ring | `rgba(252,196,40,.55)` | `rgba(252,211,77,.45)` | Focus ring (3px) |
| hover | `#f1f3f7` | `#212b42` | Hover bg for ghost buttons/rows |
| sel | `#fdf3cf` | `#3a3622` | Selected row bg, current page button |
| skel | `#eceff4` | `#263049` | Skeleton bars |
| overlay | `rgba(20,27,45,.5)` | `rgba(5,8,16,.7)` | Modal backdrop |
| side | `#1d2740` | `#0d1320` | Sidebar bg (sidebar is dark in both modes; dark mode adds a `#222b40` right border) |
| accent | `#fcd34d` | same | Brand yellow: logo, active nav, avatar |
| onAccent | `#1b2540` | same | Text on accent |
| sidebar text | `#c3cbda` idle, `#9aa6bd` section label, `#ffffff` hover, `#fcd34d` active | | |
| card shadow | `0 1px 2px rgba(20,27,45,.04), 0 4px 16px rgba(20,27,45,.05)` | `none` | |
The brand is **dark navy + sunny yellow**. In light mode the primary is navy and yellow is only an accent. In dark mode yellow becomes the primary.
### Typography
- UI font: **DM Sans** (Google Fonts, opsz 9..40, weights 400/500/600/700). Base size 14px, line-height 1.5.
- Mono: **DM Mono** 400/500. Used for the Półka shelf code and the `colorpicker` token.
- Datetimes use DM Sans with `font-variant-numeric: tabular-nums`, 13px, muted colour.
| Role | Size / weight / tracking |
|---|---|
| Page title (list) | 26px / 700 / -0.02em |
| Record title (form) | 24px / 700 / -0.02em |
| Login title | 22px / 700 / -0.02em |
| Modal title | 20px / 700 / -0.01em |
| Section title (Redaktorzy) | 17px / 700 |
| Body / cell | 14px / 400 (Nazwa cell 600) |
| Field label | 14px / 600 |
| Column header | 12px / 600, muted (the sorted column uses the text colour) |
| Helper / meta / error text | 13px |
### Radii
Inputs, buttons and selects: 10px. Cards: 16px. Login card: 16px. Modal: 20px. Inner table container and toggle cards: 12px. Tabs track: 12px, tab pill: 9px. Checkbox: 5px (6px in the form and modal). Pagination buttons: 8px. Badges, chips and switch: 999px. Avatars: 50%.
### Spacing
Page padding is 28px top/bottom and 32px left/right. Gaps between cards and blocks are 20px. Form card padding is 28px. The form grid gap is 22px (rows) × 24px (columns). Table cells use 14px horizontal padding, with 20px on the first and last columns.
### Control heights
- Primary/secondary buttons and search: **42px**
- Form inputs, selects and login inputs: **44px**
- Table header row: 44px. Body row: **54px** (56px in the relation manager). Modal option row: 54px.
- Pagination buttons: 34px
- Top header: 64px. Sidebar nav item: 40px.
### Focus
Every interactive element shows a visible **3px yellow ring** (`box-shadow: 0 0 0 3px var(--ring)`). Inputs also switch their border to `primary`. Buttons use `outline: 3px solid var(--ring); outline-offset: 2px`. Style `:focus-visible` this way and never remove it.
---
## Screens
### 1. Login (`screen=login`)
- Full-viewport background: `radial-gradient(ellipse at 50% 35%, #2b3a5c 0%, #1d2740 60%)`.
- A centred column, max-width 400px, gap 24px:
- **Wordmark**: sun icon (lucide `sun`, 20px, yellow) in a 34px circle filled `rgba(252,211,77,.14)`, followed by "Summer**CMS**" at 18px/700 in white, with "CMS" in yellow. This is the only branding, because the admin URL is custom (e.g. `/plytadmin`).
- **Card**: white background, radius 16, padding 32, gap 18, shadow `0 24px 60px rgba(0,0,0,.35)`.
- Title: "Witaj ponownie"
- Field "Login lub e-mail" (input 44px)
- Field "Hasło" (password)
- Error state: the password input gets a danger border and `aria-invalid`. Below it sits an alert block (`role="alert"`, dangerSoft bg, danger text, radius 10, padding 12×14, `circle-alert` icon 18px) reading **"Nieprawidłowe dane logowania"**.
- Full-width primary button **"Zaloguj"** (44px).
### 2. Navigation shell — multi-plugin (rail + section panel)
SummerCMS replaces WinterCMS. It is a backend that hosts many plugins, and each plugin registers navigation the way Winter's `registerNavigation()` does: **one main-menu item** (label, icon, order, permissions) plus **its own side menu** (items, icons, permissions). Płytarium is just one of these plugins. The shell is built from that registry, and nothing in it is hardcoded per plugin.
**Plugin rail (level 1 = Winter main menu)**
- Always visible. Width 80px, colour `side` (`#1d2740` light / `#0d1320` dark), padding 14px 0 12px, column gap 6, centred.
- Top: brand badge, a 40px rounded square (radius 12) filled `rgba(252,211,77,.14)` holding a `sun` icon (22px, yellow). `title="SummerCMS"`, bottom margin 14.
- One item per registered main-menu entry, sorted by `order`:
- 68px wide, padding 8/6, radius 12, letter-spacing -0.01em.
- 20px icon, with the label below it at 11px (line-height 1.2, centred).
- Idle: `#c3cbda`. Hover: `rgba(255,255,255,.08)` with white text.
- Active: bg `rgba(252,211,77,.14)`, text/icon `#fcd34d`, weight 700, `aria-current="page"`.
- Focus: 3px ring.
- Mock entries: **Płytarium** (`disc-3`), **Użytkownicy** (`users`), **Media** (`image`).
- A flex spacer, then **Ustawienia** (`settings`) pinned to the bottom. Settings is a separate area where plugins register settings pages, the same as Winter.
- When the panel is collapsed, a 40px "Rozwiń menu" button (`panel-left-open`) appears above Ustawienia.
- Wrap the rail in `<nav aria-label="Wtyczki">`.
**Section panel (level 2 = the active plugin's side menu)**
- Width 224px, `surface` bg, 1px `border` on the right, padding 14×12, gap 2.
- Header: 40px tall, 14px bottom margin. It shows the plugin label at 16px/700 (e.g. "Płytarium") and a 30px "Zwiń menu" button (`panel-left-close`).
- Items: 40px tall, radius 10, 18px icon, gap 12.
- Idle: `muted`, weight 500. Hover: `hover` bg with `text` colour.
- Active: `sel` bg (`#fdf3cf` / `#3a3622`), `text` colour, weight 600, `aria-current="page"`.
- Płytarium items: Albumy (`disc-3`), Artyści (`mic-vocal`), Kolekcje (`library`), Gatunki (`tags`).
- **"Style" is absent** because the current admin lacks permission for it.
- Użytkownicy items: Użytkownicy (`users`), Grupy (`users-round`), Role (`shield-check`).
**Switching plugins**
- Clicking a rail item navigates to that plugin's first permitted side-menu item (or to its registered default URL).
- The panel content changes, and the first breadcrumb changes to the plugin label ("Płytarium › Albumy", "Użytkownicy › Użytkownicy").
- The active rail item is derived from the current route's owning plugin, e.g. `/plytadmin/plytarium/albums` → `plytarium`.
**Permissions**
- Filter both levels. An item the user can't access is not rendered at all; don't show it disabled.
- A plugin whose side menu ends up empty after filtering is not shown in the rail either.
**Collapsed / tablet**
- Below about 1100px, or when the user collapses it manually, the panel is hidden and only the rail remains. The manual choice persists in localStorage.
- In that state, hovering or focusing a rail item, or pressing Enter/↓ on it, opens a **flyout** with the plugin's side menu:
- Position: 8px right of the rail, aligned with the header area.
- Size and shape: 230px wide, `surface` bg, border, radius 14, padding 8.
- Shadow: `0 16px 40px rgba(20,27,45,.18)`.
- Contents: a bold plugin title, then items 38px tall with radius 9 and the same active and idle styling as the panel.
- It uses `role="menu"`, closes on Esc or mouse-leave with a ~200ms delay, and returns focus to the rail item.
**Suggested registry shape** (what the backend exposes to the SPA):
```json
[{ "code": "plytarium", "label": "Płytarium", "icon": "disc-3", "order": 100,
"url": "/plytarium/albums",
"sideMenu": [{ "code": "albums", "label": "Albumy", "icon": "disc-3", "url": "/plytarium/albums" }, …] }]
```
The server filters this by permissions before sending it.
Tip: when there are many plugins, add a Ctrl K command palette that searches every plugin's side-menu entries.
**Top header**
- 64px tall, surface bg, bottom border, padding `0 24px 0 32px`.
- Left: breadcrumbs "Płytarium › Albumy › Enigmatic". Parent crumbs are muted links; the current crumb uses the text colour at weight 600. Separator: `chevron-right` 14px.
- Right: user button, 44px tall, radius 12, containing:
- 34px yellow avatar with initials "TW"
- Name "Tomasz Wójcik" (600) above role "Administrator" (12px muted). The name and role hide on tablet.
- `chevron-down`
- **User menu** (dropdown, `role="menu"`): 280px wide, radius 14, padding 8, shadow `0 16px 40px rgba(20,27,45,.16)`. It holds a header (40px avatar, "Tomasz Wójcik" in bold, "tomasz.wojcik@plytarium.pl" in muted), a divider, and the menu item "Wyloguj" (`log-out` icon).
### 3. List — Albumy (`screen=list`)
**Heading row**
- "Albumy" (26/700) with the subtitle "348 pozycji w katalogu".
- Primary button **"Nowy album"** (`plus` icon), aligned right.
**Card** (radius 16, border, shadow), containing a toolbar, a table and a pagination footer.
**Toolbar** (padding 16×20)
- Search input, 320px wide, `subtle` fill, `search` icon at left 14px. Placeholder: "Szukaj albumów…".
- Right side:
- No rows selected: **"Usuń zaznaczone"** shows as a disabled outline button (opacity .6, `not-allowed`, native `disabled`).
- 1+ rows selected: a pill "Zaznaczono 3" (sel bg, `check` icon) appears next to an enabled danger-outline button "Usuń zaznaczone" (`trash-2`, hover dangerSoft).
**Table** — min-width 920px; scrolls horizontally inside the card on narrow screens. The header is sticky with `subtle` bg and borders top and bottom. Columns in order:
1. **Checkbox** (52px). The header checkbox is tri-state: empty, `mixed` (minus icon) or checked.
2. **Nazwa**: sortable, currently ascending. The header shows `arrow-up` 14px in the text colour with `aria-sort="ascending"`. Cells are weight 600 and link to the edit form.
3. **Artyści**: relation column, comma-joined names, muted.
4. **Format** (110px): a pill (26px tall, subtle bg) with an icon. Winyl uses `disc-3`, CD uses `disc`, Kaseta uses `cassette-tape`.
5. **Gatunek**: shows "—" (muted) when empty.
6. **Półka** (72px): DM Mono 13px.
7. **Utworzono** (150px): `YYYY-MM-DD HH:mm`, muted, 13px, tabular numbers.
8. **Wypożyczony** (128px): switch column rendered as a badge. "Tak" is a green pill with an `arrow-up-right` icon; "Nie" is a muted outline pill.
- Row: 54px tall with a bottom border. Selected rows use the `sel` bg.
- Column headers are 12px/600 and muted, except the sorted column, which uses the text colour.
**Footer** (padding 14×20, top border, 13px muted)
- "1–12 z 348"
- "Na stronę" followed by a select button showing "12"
- Right-aligned pager:
- "Poprzednia" (disabled on page 1)
- Page buttons 1, 2, 3, …, 29. The current page has `sel` bg, weight 700 and `aria-current`.
- "Następna"
**States**
- *Selected*: in the mock, rows 3, 6 and 7 are selected (Enigmatic, Nevermind, Nowe sytuacje).
- *Empty*:
- 80px vertical padding inside the table area.
- 56px circle containing a `search-x` icon.
- Title "Nic nie znaleziono" (17/700).
- Text: "Żaden album nie pasuje do frazy „kaseta 1983”. Spróbuj innej nazwy lub artysty."
- Outline button "Wyczyść wyszukiwanie".
- The footer shows "0 wyników" with no pager.
- *Loading*:
- 8 skeleton rows at the same row height.
- Bars are 12px tall with radius 6. Pill placeholders match the badge sizes. Bar widths vary per row.
- The footer text reads "Wczytywanie…".
**Sample rows** (sorted by Nazwa). Columns: Nazwa · Artyści · Format · Gatunek · Półka · Utworzono · Wypożyczony.
| Nazwa | Artyści | Format | Gatunek | Półka | Utworzono | Wypożyczony |
|---|---|---|---|---|---|---|
| Astigmatic | Krzysztof Komeda | Winyl | Jazz | J-02 | 2026-09-21 14:32 | Nie |
| Ella and Louis | Ella Fitzgerald, Louis Armstrong | Winyl | Jazz | J-04 | 2026-09-20 19:05 | Tak |
| Enigmatic | Czesław Niemen, Akwarium | Winyl | Rock progresywny | R-11 | 2026-09-18 10:47 | Nie |
| Kind of Blue | Miles Davis | Winyl | Jazz | J-01 | 2026-09-17 21:13 | Nie |
| Legenda | Armia | Kaseta | Punk | K-03 | 2026-09-15 08:26 | Nie |
| Nevermind | Nirvana | CD | Grunge | C-07 | 2026-09-14 16:58 | Tak |
| Nowe sytuacje | Republika | Kaseta | Nowa fala | K-01 | 2026-09-12 12:09 | Nie |
| OK Computer | Radiohead | CD | Rock alternatywny | C-12 | 2026-09-10 18:40 | Nie |
| Rumours | Fleetwood Mac | Winyl | — | R-04 | 2026-09-08 20:15 | Tak |
| Spokojnie | Kult | Winyl | Rock | R-02 | 2026-09-05 11:31 | Nie |
| Tutu | Miles Davis | CD | Jazz fusion | C-03 | 2026-09-03 09:52 | Nie |
| Unknown Pleasures | Joy Division | Winyl | Post-punk | R-09 | 2026-09-01 22:04 | Nie |
### 4. Form — Edycja albumu (`screen=form`)
Content column max-width 980px, gap 20.
**Header row**
- 40px back button (`arrow-left`, radius 10, border) labelled "Wróć do listy".
- Title "Enigmatic" (24/700) with the subtitle "Edycja albumu · Czesław Niemen, Akwarium".
- **Tabs** on the right, styled as a segmented control:
- Track: subtle bg, 1px border, radius 12, padding 4.
- Tab: 34px tall, radius 9, 600 weight.
- Active tab: surface bg with shadow `0 1px 3px rgba(20,27,45,.12)`.
- Tabs: **"Podstawowe"** and **"Szczegóły"**, with `role="tablist"/"tab"` and `aria-selected`.
**422 error banner** (errors state only): dangerSoft bg, radius 12, padding 14×18, `circle-alert` icon 20px. Text: "**Nie udało się zapisać.** Popraw 2 pola oznaczone poniżej."
**Form card**: radius 16, padding 28.
- Grid: `repeat(auto-fit, minmax(280px, 1fr))`, gap 22×24.
- In the real implementation, map the YAML `span` value to columns: `left` → column 1, `right` → column 2, `full` → `grid-column: 1 / -1`. Below about 600px both columns collapse to one.
- FormField row: label (600, with a red `*` for required fields that is `aria-hidden`; mark the input `required` / `aria-required`), then the control, then the error message.
- Error message: 13px, danger colour, `circle-alert` 14px icon, linked through `aria-describedby`.
- The input border turns danger and the input gets `aria-invalid="true"`.
**Podstawowe tab fields**
| Field | Type | Span | Notes |
|---|---|---|---|
| Nazwa * | text | left | Error: "Pole Nazwa jest wymagane." (value empty) |
| Artyści * | multi-select relation with search | right | Chips: 30px pill, subtle bg, 22px primary-colour initials avatar, name (600), and a remove button labelled "Usuń: {name}". An inline input "Dodaj artystę…" opens a searchable dropdown. |
| Format * | dropdown | left | Options: Winyl / CD / Kaseta. The selected value shows its format icon. |
| Gatunek | relation dropdown | right | First option is **"— brak —"**, shown muted when selected. |
| Półka | text | left | Error: "Pole Półka może mieć maksymalnie 12 znaków." (value "Regał w salonie, góra") |
| Opis | textarea, 4 rows | full | Resizes vertically. |
| Wypożyczony | switch | left | Rendered as a toggle card: border, radius 12, padding 14×16. Title "Wypożyczony", helper text "Płyta jest aktualnie poza domem", and a 44×26 switch (`role="switch"`, off state uses the borderStrong track). |
| Ulubiony | checkbox | right | Toggle card with helper text "Pokazuj na początku listy" and a 20px checkbox, checked in the mock. |
**Szczegóły tab fields**
- Rok wydania (1970)
- Wytwórnia (Polskie Nagrania Muza)
- Numer katalogowy (XL 0569)
- Stan nośnika (select: "Bardzo dobry (VG+)")
- Kolor okładki, which uses the **unknown field type** fallback:
- 1.5px dashed borderStrong border, subtle bg, radius 10, min-height 44.
- `puzzle` icon with the text "Nieobsługiwany typ pola: `colorpicker`" (13px muted; the type name in DM Mono, text colour).
- The FieldRenderer should fall back to this for any unregistered `type`.
(These Szczegóły fields are placeholder content; the real ones come from the YAML.)
**Sticky footer**: surface bg, top border, padding 14×32, always visible below the scrolling form.
- Left: **"Usuń"**, a danger outline button with `trash-2`.
- Right, in order:
- "Anuluj" (ghost, muted)
- "Zapisz i zamknij" (outline)
- **"Zapisz"** (primary)
**Toast after save**
- Bottom-centre, 88px from the bottom, so it clears the footer.
- Navy `#1d2740` bg, white text, radius 14, shadow `0 16px 40px rgba(0,0,0,.25)`.
- 26px yellow circle with a check icon.
- Text: "Album „Enigmatic” został zapisany".
- Close button with `aria-label="Zamknij"`.
- Use `role="status"` and auto-dismiss after about 4s.
### 5. Relation manager — Kolekcje → Redaktorzy (`screen=relation`)
- The record header shows "Kolekcja domowa" with the subtitle "Edycja kolekcji". Tabs: "Podstawowe" and **"Redaktorzy"** (active).
- **The "Redaktorzy" tab (span full) renders only when editing an existing record, never on create.**
**Section header**
- "Redaktorzy" (17/700) with the helper text "Osoby, które mogą edytować tę kolekcję".
- On the right:
- Search, 220px wide, 38px tall, placeholder "Szukaj…"
- "Usuń zaznaczonych" (danger outline, `user-minus`)
- **"Dodaj redaktora"** (primary, `user-plus`)
- These buttons are 38px tall, one size down from page level.
**Table**: the same DataTable, inside a bordered 12px-radius container.
- Columns: checkbox, **Imię i nazwisko** (30px initials avatar plus name, 600), **E-mail** (muted), **Dodano** (160px, datetime).
- Rows are 56px tall. Row selection works the same as the main list. In the mock, Marek Nowak is selected and the header checkbox is `mixed`.
Sample editors:
- Anna Kowalska · anna.kowalska@plytarium.pl · 2026-08-02 17:20
- Marek Nowak · marek.nowak@plytarium.pl · 2026-08-14 09:03
- Julia Wiśniewska · julia.wisniewska@plytarium.pl · 2026-09-19 20:41
**"Dodaj redaktora" modal**
- Container: `role="dialog"`, `aria-modal`, 560px wide, radius 20, padding 24, gap 16, over the backdrop overlay.
- Title "Dodaj redaktora" (20/700). Helper text: "Wybierz osoby spoza obecnych redaktorów. Właściciel kolekcji nie jest wyświetlany."
- 34px close button (`x`, subtle bg).
- Search input (44px, autofocused, so the focus ring shows), placeholder "Szukaj po nazwisku lub e-mailu…".
- Candidate list: `role="listbox"`, `aria-multiselectable`.
- Each option row: 54px tall, radius 12, border, 32px initials avatar, name (600) above email (13px muted), and a checkbox on the right.
- Selected option: sel bg and a `#e0b020` border.
- Candidates are users not yet linked to the collection, **with the owner excluded on the server side**. Results are paginated at 5 per page: "1–5 z 14", with prev/next buttons (34px, radius 9).
- Footer: two equal-width buttons, "Anuluj" (outline) and **"Dodaj (N)"** (primary). N is the selection count; the button is disabled when N = 0.
- Mock: Piotr Zieliński and Katarzyna Lewandowska are selected, so the button reads "Dodaj (2)". Other candidates: Michał Kamiński, Agnieszka Dąbrowska, Paweł Szymański.
- Keyboard: Esc closes, focus is trapped inside, and focus returns to "Dodaj redaktora" on close.
---
## Interactions & Behavior
- **Sorting**: clicking a sortable header cycles asc → desc → none. Show `arrow-up` / `arrow-down`, and reflect the state in `aria-sort` and the URL query.
- **Search**: debounce at 300ms, reset to page 1, and keep the term in the URL.
- **Selection**:
- The header checkbox selects the current page.
- Selection resets on page or search change.
- Bulk delete opens a confirm dialog: "Usunąć N albumów?".
- **Pagination**: per-page options 12 / 24 / 48 / 96. Range text uses the format "{from}–{to} z {total}" with an en dash.
- **Loading**: show skeleton rows in place of the table body. Keep the toolbar and footer mounted.
- **Validation**:
- On a 422 `{errors: {field: [msg]}}` response, show the messages under each field plus the top banner, and focus the first invalid field.
- Errors clear per field on change.
- When the invalid field sits on another tab, mark that tab with a count badge.
- **Save**:
- "Zapisz" stays on the form and shows the toast.
- "Zapisz i zamknij" returns to the list and shows the toast there.
- "Anuluj" returns without saving; confirm first if the form is dirty.
- "Usuń" opens a confirm dialog.
- **Relation manager**: "Dodaj redaktora" opens the modal, and confirming POSTs the selected IDs. The table then refreshes and a toast reads "Dodano 2 redaktorów". "Usuń zaznaczonych" asks for confirmation, then detaches.
- **Transitions**: use subtle ones only, 150ms ease-out for hover/background and 200ms for modal fade and scale (0.98 → 1). No decorative animation.
- **Responsive**:
- 1280px is the design width.
- Below about 1100px the sidebar collapses to 72px icons; users can also toggle this manually and the choice persists.
- At tablet width (≥768px) tables scroll horizontally inside their card and the form grid wraps to one column when needed. The header user button hides its name.
## State Management
- Global: auth user and permissions (these drive which menu items render), plus the sidebar collapsed flag.
- Per list: `{ search, sort: {column, dir}, page, perPage, selectedIds, loading, rows, total }`, synced to the URL query.
- Per form: `{ record, dirty, errors: Record<field, string[]>, activeTab, saving, mode: 'create'|'update' }`. Tabs and fields come from the YAML schema; relation tabs require `mode === 'update'`.
- Relation modal: `{ open, search, page, candidates, total, selectedIds }`.
- Toast queue: global.
## Components to build (generic)
- `PluginRail`, `SectionPanel`, `SectionFlyout` (all driven by the navigation registry)
- `AppShell` (sidebar + header)
- `DataTable` (columns from schema; column types `text`, `relation`, `datetime`, `switch`, `badge`/`format`; selection; sort; states)
- `ListToolbar`
- `Pagination`
- `FormTabs`
- `FormGrid`
- `FormField` (label/control/error row; `span`)
- `FieldRenderer` (type registry → `text`, `textarea`, `dropdown`, `relation`, `relation-multi`, `switch`, `checkbox`, fallback `UnsupportedField`)
- `RelationManager`
- `RelationPickerModal`
- `Toast`
- `UserMenu`
## Assets
- Icons: **lucide** (the mock uses the `lucide-static` icon font). Use `lucide-vue-next` in production. Icons used:
- Navigation and brand: `sun`, `disc-3`, `disc`, `cassette-tape`, `mic-vocal`, `library`, `tags`, `settings`, `panel-left-close`
- Chevrons and arrows: `chevron-right`, `chevron-down`, `chevron-left`, `arrow-up`, `arrow-left`, `arrow-right`, `arrow-up-right`
- Actions and status: `search`, `search-x`, `plus`, `trash-2`, `check`, `minus`, `x`, `circle-alert`, `user-plus`, `user-minus`, `log-out`, `puzzle`
- Fonts: DM Sans and DM Mono from Google Fonts.
- No images. The logo is the lucide `sun` icon plus the text wordmark.
## Files
- `Direction C v2.dc.html`: the full design reference, with every screen and state switchable via props.
- `support.js`: runtime needed to open the HTML file locally. It is not part of the design.

File diff suppressed because it is too large Load Diff