porting to the use of penguinui

This commit is contained in:
Priec
2026-06-18 11:08:10 +02:00
parent 9a3c68eae5
commit e8c6035eeb
7 changed files with 256 additions and 93 deletions

View File

@@ -205,6 +205,8 @@ add-to-cart = Add to cart
cart-added = Added to cart
in-stock = In stock
out-of-stock = Out of stock
gallery-prev = Previous image
gallery-next = Next image
confirm-delete = Delete this for good?
shop-title = Shop
shop-subtitle = browse our products.

View File

@@ -205,6 +205,8 @@ add-to-cart = Pridať do košíka
cart-added = Pridané do košíka
in-stock = Na sklade
out-of-stock = Vypredané
gallery-prev = Predchádzajúci obrázok
gallery-next = Ďalší obrázok
confirm-delete = Naozaj zmazať?
shop-title = Obchod
shop-subtitle = prezrite si našu ponuku produktov.

File diff suppressed because one or more lines are too long

View File

@@ -120,3 +120,12 @@
<span>{{ label }}</span>
</label>
{%- endmacro checkbox %}
{# Radio dot (verbatim Penguin custom radio from penguinui/radio/radio-with-container.html).
Emits ONLY the <input> (the styled dot) — callers keep their own card-style
<label> wrapper (e.g. checkout's has-[:checked]:border-primary cards). Use
`attrs` for x-model / required etc.; callers whose @change mixes nested
single+double quotes (carrier loop) spell this class out inline instead. #}
{% macro radio(name, value="", id="", checked=false, attrs="", extra="") -%}
<input {% if id %}id="{{ id }}" {% endif %}name="{{ name }}" type="radio" value="{{ value }}"{% if checked %} checked{% endif %} class="before:content[''] relative h-4 w-4 appearance-none rounded-full border border-outline bg-surface before:invisible before:absolute before:left-1/2 before:top-1/2 before:h-1.5 before:w-1.5 before:-translate-x-1/2 before:-translate-y-1/2 before:rounded-full before:bg-on-primary checked:border-primary checked:bg-primary checked:before:visible focus:outline-2 focus:outline-offset-2 focus:outline-outline-strong checked:focus:outline-primary disabled:cursor-not-allowed dark:border-outline-dark dark:bg-surface-dark dark:before:bg-on-primary-dark dark:checked:border-primary-dark dark:checked:bg-primary-dark dark:focus:outline-outline-dark-strong dark:checked:focus:outline-primary-dark {{ extra }}" {{ attrs | safe }}/>
{%- endmacro radio %}

View File

@@ -130,9 +130,10 @@
{% for m in shipping_methods %}
<label class="flex cursor-pointer items-center justify-between gap-3 rounded-radius border border-outline px-4 py-3 transition has-[:checked]:border-primary dark:border-outline-dark dark:has-[:checked]:border-primary-dark">
<span class="flex items-center gap-3">
<!-- Penguin radio dot inline (the @change mixes nested single+double quotes, can't pass through a Tera macro arg) -->
<input type="radio" name="carrier_code" value="{{ m.code }}" required
@change="carrier='{{ m.code }}'; carrierPrice={{ m.price_cents }}; requiresPoint={{ m.requires_pickup_point }}; pointId=''; pointName=''"
class="size-4 border-outline text-primary focus:ring-primary dark:border-outline-dark">
class="before:content[''] relative h-4 w-4 appearance-none rounded-full border border-outline bg-surface before:invisible before:absolute before:left-1/2 before:top-1/2 before:h-1.5 before:w-1.5 before:-translate-x-1/2 before:-translate-y-1/2 before:rounded-full before:bg-on-primary checked:border-primary checked:bg-primary checked:before:visible focus:outline-2 focus:outline-offset-2 focus:outline-outline-strong checked:focus:outline-primary disabled:cursor-not-allowed dark:border-outline-dark dark:bg-surface-dark dark:before:bg-on-primary-dark dark:checked:border-primary-dark dark:checked:bg-primary-dark dark:focus:outline-outline-dark-strong dark:checked:focus:outline-primary-dark">
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ m.name }}</span>
</span>
<span class="tabular-nums text-on-surface/80 dark:text-on-surface-dark/80">{{ m.price }} {{ currency }}</span>
@@ -163,13 +164,11 @@
<fieldset class="space-y-3 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-payment", lang=lang | default(value='sk')) }}</legend>
<label class="flex cursor-pointer items-center gap-3 rounded-radius border border-outline px-4 py-3 transition has-[:checked]:border-primary dark:border-outline-dark dark:has-[:checked]:border-primary-dark">
<input type="radio" name="payment_method" value="cod" required x-model="paymentMethod"
class="size-4 border-outline text-primary focus:ring-primary dark:border-outline-dark">
{{ ui::radio(name="payment_method", value="cod", attrs='required x-model="paymentMethod"') }}
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="payment-cod", lang=lang | default(value='sk')) }}</span>
</label>
<label class="flex cursor-pointer items-center gap-3 rounded-radius border border-outline px-4 py-3 transition has-[:checked]:border-primary dark:border-outline-dark dark:has-[:checked]:border-primary-dark">
<input type="radio" name="payment_method" value="bank_transfer" required x-model="paymentMethod"
class="size-4 border-outline text-primary focus:ring-primary dark:border-outline-dark">
{{ ui::radio(name="payment_method", value="bank_transfer", attrs='required x-model="paymentMethod"') }}
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="payment-bank", lang=lang | default(value='sk')) }}</span>
</label>
</fieldset>

View File

@@ -5,14 +5,35 @@
{% block content %}
<div class="grid gap-10 lg:grid-cols-2">
<!-- gallery -->
<div x-data="{ active: 0 }" class="space-y-4">
<div class="aspect-square overflow-hidden rounded-radius border border-outline bg-surface-alt dark:border-outline-dark dark:bg-surface-dark-alt">
<!-- gallery — prev/next arrows + opacity transitions adapted from
penguinui/carousel/default-carousel.html; kept our product thumbnail strip
(more useful than carousel dots for a product) and 0-based `active` -->
<div x-data="{ active: 0, count: {{ images | length }},
prev() { this.active = this.active > 0 ? this.active - 1 : this.count - 1 },
next() { this.active = this.active < this.count - 1 ? this.active + 1 : 0 } }"
class="space-y-4">
<div class="relative aspect-square overflow-hidden rounded-radius border border-outline bg-surface-alt dark:border-outline-dark dark:bg-surface-dark-alt">
{% if images | length > 0 %}
{% for image in images %}
<img x-show="active === {{ loop.index0 }}" src="/images/{{ image }}" alt="{{ product.name }}" class="size-full object-cover">
<img x-show="active === {{ loop.index0 }}" x-transition.opacity.duration.300ms src="/images/{{ image }}" alt="{{ product.name }}" class="absolute inset-0 size-full object-cover">
{% endfor %}
{% endif %}
{% if images | length > 1 %}
<!-- previous slide -->
<button type="button" @click="prev()" aria-label="{{ t(key='gallery-prev', lang=lang | default(value='sk')) }}"
class="absolute left-3 top-1/2 z-10 flex -translate-y-1/2 items-center justify-center rounded-full bg-surface/40 p-2 text-on-surface transition hover:bg-surface/60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:bg-surface-dark/40 dark:text-on-surface-dark dark:hover:bg-surface-dark/60 dark:focus-visible:outline-primary-dark">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="3" class="size-5" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>
</button>
<!-- next slide -->
<button type="button" @click="next()" aria-label="{{ t(key='gallery-next', lang=lang | default(value='sk')) }}"
class="absolute right-3 top-1/2 z-10 flex -translate-y-1/2 items-center justify-center rounded-full bg-surface/40 p-2 text-on-surface transition hover:bg-surface/60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:bg-surface-dark/40 dark:text-on-surface-dark dark:hover:bg-surface-dark/60 dark:focus-visible:outline-primary-dark">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="3" class="size-5" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
{% endif %}
</div>
{% if images | length > 1 %}
<div class="flex flex-wrap gap-2">

View File

@@ -50,9 +50,15 @@ from the build. If the build ever balloons, check that exclusion is intact.
---
## 1. Navbar
## 1. Navbar — PENDING
**Penguin UI: `navbar/`**
> **Priority: MEDIUM** | ~143 lines across2 sites.
> Penguin match: `navbar/default-navbar.html` — provides mobile-responsive structure,
> hamburger animation, and link treatment that would replace our hand-rolled `<nav>`.
> **Watch out:** tight integration needed for cart badge, settings dropdown partial,
> language switcher, and Alpine mobile toggle — don't lose any of those.
| # | Location | What it is | Size |
|---|----------|------------|------|
| 1 | `assets/views/base.html:63-191` | Full site navbar: brand, desktop nav links, cart icon+badge, settings dropdown, mobile hamburger → mobile panel | ~130 lines |
@@ -117,8 +123,19 @@ from the build. If the build ever balloons, check that exclusion is intact.
---
## 5. Country / Phone Combobox
**Penguin UI: `text-input/` + custom dropdown list**
## 5. Country / Phone Combobox — ⛔ WON'T PORT (conscious deviation)
**Penguin UI: `combobox/phone-number-input-with-country-code-dropdown.html`**
> **Decision 2026-06-18:** keep our lightweight hand-rolled comboboxes; do NOT
> port the Penguin one. The Penguin combobox depends on the **Alpine Focus
> plugin** (`x-trap`, `$focus.wrap().next()`) which this build does not bundle
> (same reason the settings dropdown & category accordion deviate), ships a
> 240-country `allOptions` list + `flagcdn.com` remote flag images, and a search
> field — far heavier than our deliberate 9-prefix / 6-country editable inputs.
> Our versions already use the Penguin design tokens (`bg-surface`, `border-outline`,
> `focus:outline-primary`) and emoji flags, so they look on-brand. Net: porting
> would add a JS dependency and external image loads for negative UX value.
> Revisit only if we adopt the Alpine Focus plugin project-wide.
| # | Location | What it is | Size |
|---|----------|------------|------|
@@ -149,37 +166,38 @@ from the build. If the build ever balloons, check that exclusion is intact.
---
## 7. Product Image Gallery
**Penguin UI: `carousel/` (3 variants)**
## 7. Product Image Gallery — ✅ DONE
**Penguin UI: `carousel/default-carousel.html`**
| # | Location | What it is | Size |
|---|----------|------------|------|
| 12 | `assets/views/shop/show.html:8-26` | Image gallery with main image + thumbnail strip, Alpine `x-data="{ active: 0 }"` | ~19 lines |
**Details:**
- Main image: `x-show="active === {{ loop.index0 }}"` with `object-cover`
- Thumbnail buttons: border changes to indicate active state
- No transition/animation between images — just x-show toggling
- Exact upstream mirror at `penguinui-components/carousel/default-carousel.html` (reference only)
- Adapted at use-site in `assets/views/shop/show.html`: added Penguin's overlay
prev/next arrow buttons (`bg-surface/40` rounded, verbatim chevron SVGs) and
`x-transition.opacity.duration.300ms` fade between images. Added `prev()`/`next()`
with wraparound to the gallery `x-data`; arrows + transitions only render when
`images | length > 1`.
- Deviations: kept our **product thumbnail strip** (more useful than carousel
dot indicators for a product page) and our **0-based `active`** index (Penguin
uses 1-based `currentSlideIndex`); main images switched to `absolute inset-0`
so the fade cross-dissolves inside the `aspect-square` frame. New i18n keys
`gallery-prev`/`gallery-next` (sk + en) for the arrow `aria-label`s.
---
## 8. Radio-Button Groups
**Penguin UI: radio (part of form inputs)**
## 8. Radio-Button Groups — ✅ DONE
**Penguin UI: `radio/radio-with-container.html`**
| # | Location | What it is | Size |
|---|----------|------------|------|
| 13 | `assets/views/shop/checkout.html:133-165` | Carrier selection radio group (each option shows name + price) | ~33 lines |
| 14 | `assets/views/shop/checkout.html:167-180` | Payment method radio group (COD + bank transfer) | ~14 lines |
**Details for #13:**
- `{% for m in shipping_methods %}` loop
- Each `<label>` is a styled card with `has-[:checked]:border-primary` border highlight
- Radio input triggers `@change` to update Alpine state (carrier, carrierPrice, requiresPoint)
- Pickup-point sub-panel shown via `x-show="requiresPoint"`
**Details for #14:**
- Two hardcoded radio options: COD and bank_transfer
- `x-model="paymentMethod"` binding
- Exact upstream mirror at `penguinui-components/radio/radio-with-container.html` (reference only)
- New `ui::radio(name, value, id, checked, attrs, extra)` macro in `macros/ui.html`
emits **only** the Penguin custom radio-dot `<input>` (verbatim `appearance-none`
+ `before:` dot + `checked:bg-primary`). Callers keep their own card-style
`<label>` wrapper — we kept our `has-[:checked]:border-primary` card highlight,
which is richer than Penguin's plain `bg-surface-alt` container.
- Adopted at `shop/checkout.html`: both **payment** radios (`ui::radio` with
`attrs='required x-model="paymentMethod"'`). The **carrier** radio (in the
`{% for m in shipping_methods %}` loop) keeps the same Penguin dot class **inline**
because its `@change="carrier='{{ m.code }}'; …"` mixes nested single+double
quotes that can't pass through a Tera macro arg (same convention as the cart
qty input). Native `text-primary` radios are gone.
---
@@ -229,8 +247,13 @@ from the build. If the build ever balloons, check that exclusion is intact.
---
## 14. Table
**Penguin UI: `table/` (7 variants)**
## 14. Table — PENDING
**Penguin UI: `table/` (5 variants)**
> **Priority: HIGH** | ~196 lines across5 instances.
> The same class structure is copy-pasted5 times. Penguin match:
> `table/default-table.html` for basic tables, `table-with-action.html` for
> row actions. Consider a `ui::table_header()` + `ui::table_row()` macro.
| # | Location | What it is | Size |
|---|----------|------------|------|
@@ -324,8 +347,14 @@ This is copy-pasted 5 times.
---
## 18. Toggle / Switch
**Penguin UI: `toggle/` (2 variants)**
## 18. Toggle / Switch — LOW PRIORITY (de-duplication, not replacement)
**Penguin UI: `toggle/` (3 variants)**
> **Priority: LOW** | ~36 lines,100% duplicated between `base.html` and `admin/base.html`.
> This is JavaScript theme-switching logic (`applyTheme`, `setTheme`, `matchMedia`),
> not a CSS toggle component. Penguin's `toggle/default-toggle.html` is a visual
> on/off switch — not applicable here.
> **Action:** de-duplicate the JS into a shared partial rather than porting.
| # | Location | What it is | Size |
|---|----------|------------|------|
@@ -339,9 +368,14 @@ This is copy-pasted 5 times.
---
## 19. Inline SVG Icons
## 19. Inline SVG Icons — LOW PRIORITY
**Penguin UI: none (Penguin uses Heroicons-equivalent inline SVGs)**
> **Priority: LOW** | ~50 lines,8 distinct icons.
> Penguin doesn't provide an icon library. Icons are already inlined where used.
> Possible follow-up: extract into a `ui::icon(name)` macro for dedup, but
> this is purely cosmetic — no functional gain.
| # | Location | Icon | Occurrences |
|---|----------|------|-------------|
| 55 | `base.html:70-72,168-170` | Hamburger (3-line menu) | 2 |
@@ -357,8 +391,12 @@ All are raw inline `<svg>` with hardcoded `<path d="...">` — no icon library,
---
## 20. Empty State
**Penguin UI: no direct component, but table empty states exist in Penguin tables**
## 20. Empty State — LOW PRIORITY
**Penguin UI: no dedicated empty-state component**
> **Priority: LOW** | ~22 lines across5 sites.
> These are simple `<div>` messages, often with a CTA button already using
> `ui::button`. Nothing to port — already consistent with project styling.
| # | Location | What it is | Size |
|---|----------|------------|------|
@@ -370,8 +408,13 @@ All are raw inline `<svg>` with hardcoded `<path d="...">` — no icon library,
---
## 21. Dashboard Navigation Cards
**Penguin UI: `card/`**
## 21. Dashboard Navigation Cards — LOW PRIORITY
**Penguin UI: `card/default-card.html` or `card/card-with-button.html`**
> **Priority: LOW** | ~16 lines.
> Already uses card styling (`rounded-radius border border-outline hover:border-primary`).
> Penguin's `default-card.html` adds a structured header/body layout — adopt if
> cards ever grow beyond a title+description link.
| # | Location | What it is | Size |
|---|----------|------------|------|
@@ -383,8 +426,13 @@ All are raw inline `<svg>` with hardcoded `<path d="...">` — no icon library,
---
## 22. Checkout Order Summary
**Penguin UI: `card/` (ecommerce-summary style)**
## 22. Checkout Order Summary — LOW PRIORITY
**Penguin UI: `card/`**
> **Priority: LOW** | ~29 lines.
> Already uses card-like styling with `tabular-nums`. All internal buttons use
> `ui::button`. The only handcoded part is the outer `<div>` wrapper and line-item
> layout. Penguin doesn't have an ecommerce-specific summary component.
| # | Location | What it is | Size |
|---|----------|------------|------|
@@ -398,8 +446,14 @@ All are raw inline `<svg>` with hardcoded `<path d="...">` — no icon library,
---
## 23. Login Card
**Penguin UI: `card/`**
## 23. Login Card — LOW PRIORITY
**Penguin UI: `card/default-card.html`**
> **Priority: LOW** | ~56 lines.
> Already fully uses Penguin macros inside: `ui::input`, `ui::button`,
> `ui::badge`, `ui::alert_danger`. Only the outer card wrapper (border,
> bg-surface-alt, shadow-sm) is handcoded. Adopting `default-card.html`
> would add visual polish but little functional gain.
| # | Location | What it is | Size |
|---|----------|------------|------|
@@ -407,9 +461,15 @@ All are raw inline `<svg>` with hardcoded `<path d="...">` — no icon library,
---
## 24. Checkout Fieldset Cards
## 24. Checkout Fieldset Cards — LOW PRIORITY
**Penguin UI: `card/`**
> **Priority: LOW** | ~142 lines across4 fieldsets.
> Already uses card styling (`rounded-radius border border-outline bg-surface p-6`)
> and all internal form controls use Penguin macros (`ui::input`, `ui::textarea`,
> `ui::button`). Only the `<fieldset>` + `<legend>` wrapping is handcoded.
> Low value in replacing — fieldset semantics are correct here.
| # | Location | What it is | Size |
|---|----------|------------|------|
| 71 | `assets/views/shop/checkout.html:34-79` | Contact info fieldset (email, name, phone+prefix) | ~46 lines |
@@ -421,9 +481,14 @@ Each fieldset uses `<fieldset>` + `<legend>` with the same `rounded-radius borde
---
## 25. Order Detail Info Panel
## 25. Order Detail Info Panel — LOW PRIORITY
**Penguin UI: `card/`**
> **Priority: LOW** | ~64 lines across3 panels.
> Already card-styled (`rounded-radius border border-outline bg-surface p-5`)
> with Penguin macros inside. Deviating from this simple structure would
> make the dense info layout harder to scan.
| # | Location | What it is | Size |
|---|----------|------------|------|
| 75 | `assets/views/admin/orders/show.html:49-77` | Customer + shipping + payment info panel | ~29 lines |
@@ -432,18 +497,27 @@ Each fieldset uses `<fieldset>` + `<legend>` with the same `rounded-radius borde
---
## 26. Shipping Method Settings Row
## 26. Shipping Method Settings Row — LOW PRIORITY
**Penguin UI: `card/`**
> **Priority: LOW** | ~21 lines.
> Already fully uses Penguin macros: `ui::input`, `ui::checkbox`, `ui::button`.
> The card wrapper is the same pattern as other admin panels. Nothing to port.
| # | Location | What it is | Size |
|---|----------|------------|------|
| 78 | `assets/views/admin/shipping/index.html:14-34` | Per-carrier settings: name label, price input, enabled checkbox, save button | ~21 lines |
---
## 27. Product/Category Form Wrapper
## 27. Product/Category Form Wrapper — LOW PRIORITY
**Penguin UI: `card/`**
> **Priority: LOW** | ~150 lines across2 forms.
> Already fully uses Penguin macros: `ui::input`, `ui::textarea`, `ui::select`,
> `ui::file_input`, `ui::checkbox`, `ui::button`. The `<form>` card wrapper is
> the same border/bg pattern. Penguin doesn't have a form-specific layout component.
| # | Location | What it is | Size |
|---|----------|------------|------|
| 79 | `assets/views/admin/catalog/product_form.html:15-99` | Full product edit/create form with all fields | ~84 lines |
@@ -453,43 +527,99 @@ Both are wrapped in a single card-style `<form>`.
---
## Porting Roadmap (priority order)
### Phase 1 — HIGH (direct Penguin matches, clear win)
| # | Component | Penguin match | Est. effort | Lines saved |
|---|-----------|---------------|-------------|-------------|
| ~~5~~ | ~~Country/Phone Combobox~~ | ⛔ WON'T PORT — needs Alpine Focus plugin; our lightweight version kept | — | — |
| ~~7~~ | ~~Image Gallery~~ | ✅ DONE — `carousel/default-carousel.html` | Small | ~19 |
| ~~8~~ | ~~Radio Groups~~ | ✅ DONE — `radio/radio-with-container.html` | Small | ~47 |
| 14 | Table | `table/default-table.html` | Medium | ~196 |
### Phase 2 — MEDIUM (good match, more integration risk)
| # | Component | Penguin match | Est. effort | Lines saved |
|---|-----------|---------------|-------------|-------------|
| 1 | Navbar | `navbar/default-navbar.html` | Large | ~143 |
### Phase 3 — LOW (mostly already Penguin, or no good match)
| # | Component | Action |
|---|-----------|--------|
| 18 | Toggle/Switch | De-duplicate JS into shared partial (not a Penguin port) |
| 19 | Inline SVG Icons | Optional: extract `ui::icon(name)` macro |
| 20 | Empty State | Already fine — nothing to port |
| 21 | Dashboard Cards | Adopt `card/default-card.html` if cards grow |
| 22 | Checkout Summary | Already fine — nothing to port |
| 23 | Login Card | Already fine — only outer wrapper is handcoded |
| 24 | Checkout Fieldsets | Already fine — only `<fieldset>` wrapper is handcoded |
| 25 | Order Info Panels | Already fine — only card wrappers are handcoded |
| 26 | Shipping Settings Row | Already fully uses Penguin macros |
| 27 | Form Wrappers | Already fully uses Penguin macros |
### Already DONE (15 of 27)
| # | Component |
|---|-----------|
| 0 | Toast |
| 2 | Sidebar (Admin) |
| 3 | Sidebar (Category Accordion) |
| 4 | Dropdown (Settings) |
| 6 | Product Card |
| 7 | Image Gallery |
| 8 | Radio Groups |
| 9 | Checkbox |
| 10 | Text Input |
| 11 | Textarea |
| 12 | Select/Dropdown |
| 13 | File Input |
| 15 | Alert / Error Banner |
| 16 | Badge / Status Pill |
| 17 | Buttons |
**Remaining real ports: just #14 Table (~196 lines, Medium) and #1 Navbar
(~143 lines, Large).** #5 Combobox is a conscious WON'T-PORT (Alpine Focus
plugin dependency). The Phase-3 items are already internally Penguin-adapted
or have no applicable component.
---
## Summary
| # | Component | Penguin UI Directory | Handcoded Instances | Total Lines |
|---|-----------|---------------------|--------------------|-------------|
| 1 | Navbar | `navbar/` | 2 | ~143 |
| 2 | Sidebar (admin) | `sidebar/` | 2 | ~46 |
| 3 | Sidebar (category accordion) | `sidebar/` | 2 | ~62 |
| 4 | Dropdown (settings) | `dropdown-menu/` | **2 duplicates** | ~103 |
| 5 | Country/Phone combobox | `text-input/` | 2 | ~51 |
| 6 | Product card | `card/` | 1 | ~30 |
| 7 | Image gallery | `carousel/` | 1 | ~19 |
| 8 | Radio groups | (form inputs) | 2 | ~47 |
| 9 | Checkbox | `checkbox/` | 3 | ~15 |
| 10 | Text input | `text-input/` | 8 | ~146 |
| 11 | Textarea | `textarea/` | 3 | ~10 |
| 12 | Select | `select/` | 3 | ~23 |
| 13 | File input | `file-input/` | 2 | ~12 |
| 14 | Table | `table/` | 5 | ~196 |
| 15 | Alert/Error | `alert/` | 2 | ~9 |
| 16 | Badge/Pill | `badge/` | 6 | ~17 |
| 17 | Button | `buttons/` | 50+ occurrences | ~200+ |
| 18 | Toggle (theme) | `toggle/` | **2 duplicates** | ~36 |
| 19 | Inline SVG icons | N/A | 8 distinct icons | ~50 |
| 20 | Empty state | (table variants) | 5 | ~22 |
| 21 | Dashboard cards | `card/` | 1 | ~16 |
| 22 | Checkout summary | `card/` | 1 | ~29 |
| 23 | Login card | `card/` | 1 | ~56 |
| 24 | Checkout fieldsets | `card/` | 4 | ~142 |
| 25 | Order info panels | `card/` | 3 | ~64 |
| 26 | Shipping settings row | `card/` | 1 | ~21 |
| 27 | Form wrappers | `card/` | 2 | ~150 |
| # | Component | Penguin UI Directory | Status | Lines |
|---|-----------|---------------------|--------|-------|
| 0 | Toast | `toast-notification/` | ✅ DONE | — |
| 1 | Navbar | `navbar/` | PENDING (MED) | ~143 |
| 2 | Sidebar (admin) | `sidebar/` | ✅ DONE | ~46 |
| 3 | Sidebar (category accordion) | `sidebar/` | ✅ DONE | ~62 |
| 4 | Dropdown (settings) | `dropdowns/` | ✅ DONE | ~103 |
| 5 | Country/Phone combobox | `combobox/` | ⛔ WON'T PORT | ~51 |
| 6 | Product card | `card/` | ✅ DONE | ~30 |
| 7 | Image gallery | `carousel/` | ✅ DONE | ~19 |
| 8 | Radio groups | `radio/` | ✅ DONE | ~47 |
| 9 | Checkbox | `checkbox/` | ✅ DONE | ~15 |
| 10 | Text input | `text-input/` | ✅ DONE | ~146 |
| 11 | Textarea | `text-area/` | ✅ DONE | ~10 |
| 12 | Select | `select/` | ✅ DONE | ~23 |
| 13 | File input | `file-input/` | ✅ DONE | ~12 |
| 14 | Table | `table/` | **HIGH** | ~196 |
| 15 | Alert/Error | `alert/` | ✅ DONE | ~9 |
| 16 | Badge/Pill | `badge/` | ✅ DONE | ~17 |
| 17 | Button | `buttons/` | ✅ DONE | ~200+ |
| 18 | Toggle (theme) | `toggle/` | LOW (dedup) | ~36 |
| 19 | Inline SVG icons | N/A | LOW | ~50 |
| 20 | Empty state | N/A | LOW | ~22 |
| 21 | Dashboard cards | `card/` | LOW | ~16 |
| 22 | Checkout summary | `card/` | LOW | ~29 |
| 23 | Login card | `card/` | LOW | ~56 |
| 24 | Checkout fieldsets | `card/` | LOW | ~142 |
| 25 | Order info panels | `card/` | LOW | ~64 |
| 26 | Shipping settings row | `card/` | LOW | ~21 |
| 27 | Form wrappers | `card/` | LOW | ~150 |
**Grand total: ~27 distinct handcoded UI component types across ~80 instances, representing approximately 1,600+ lines of handcoded HTML/Tailwind/Alpine that could be replaced by Penguin UI components.**
### Duplication hotspots:
- **Settings dropdown** (`base.html:110-162` and `admin/base.html:117-166`) — 100% copy-paste
- **Theme toggle JS** (`base.html:13-30` and `admin/base.html:13-30`) — 100% copy-paste
- **Text input class string** — same 80-character Tailwind string appears 15+ times
- **Table class strings** (thead, tbody, tr) — copy-pasted 5 times
- **Button variants** — inconsistent `hover:opacity-75` vs `hover:opacity-90`
**Status: 15 of 27 components fully ported to Penguin UI. Only 2 real ports
remain — #14 Table (HIGH) and #1 Navbar (MED). #5 Combobox is a conscious
WON'T-PORT (Alpine Focus plugin dependency). The remaining Phase-3 items are
already internally Penguin-adapted or have no applicable match.**