toast is now from the penguin ui library and it looks so good

This commit is contained in:
Priec
2026-06-17 21:02:18 +02:00
parent 67fd364761
commit c401acb1cc
4 changed files with 837 additions and 14 deletions

File diff suppressed because one or more lines are too long

View File

@@ -48,8 +48,10 @@
return v.split(',').reduce(function (s, e) { return s + (parseInt(e.split(':')[1]) || 0) }, 0);
}
// Show a floating toast notification. Usage: toast('Saved').
// Bridges to the vendored Penguin UI toast component, which listens for a
// `notify` event with { variant, title, message }.
function toast(message) {
window.dispatchEvent(new CustomEvent('toast', { detail: message }));
window.dispatchEvent(new CustomEvent('notify', { detail: { variant: 'success', message: message } }));
}
</script>
<link href="/static/css/app.css?v=2026-06-16" rel="stylesheet" type="text/css">
@@ -210,19 +212,150 @@
</main>
</div>
<!-- toast notifications: fire from anywhere with toast('message') -->
<div x-data="{ toasts: [] }"
@toast.window="const id = Date.now() + Math.random(); toasts.push({ id, msg: $event.detail }); setTimeout(() => { toasts = toasts.filter(t => t.id !== id) }, 3000)"
class="pointer-events-none fixed bottom-4 right-4 z-50 flex flex-col gap-2">
<template x-for="t in toasts" :key="t.id">
<div x-transition.opacity.duration.300ms
class="pointer-events-auto flex items-center gap-2 rounded-radius border border-outline bg-surface px-4 py-3 text-sm font-medium text-on-surface shadow-lg dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-5 shrink-0 text-success">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
<!-- toast notifications: fire from anywhere with toast('message').
Adapted from the vendored Penguin UI component
(assets/views/penguinui/toast-notification/stacking-toast-notification.html):
the docs-only demo trigger buttons are omitted and the malformed quotes on
the upstream dismiss-button <svg> tags are fixed. -->
<div x-data="{
notifications: [],
displayDuration: 8000,
soundEffect: false,
addNotification({ variant = 'info', sender = null, title = null, message = null}) {
const id = Date.now()
const notification = { id, variant, sender, title, message }
if (this.notifications.length >= 20) {
this.notifications.splice(0, this.notifications.length - 19)
}
this.notifications.push(notification)
},
removeNotification(id) {
setTimeout(() => {
this.notifications = this.notifications.filter(
(notification) => notification.id !== id,
)
}, 400);
},
}" x-on:notify.window="addNotification({
variant: $event.detail.variant,
sender: $event.detail.sender,
title: $event.detail.title,
message: $event.detail.message,
})">
<div x-on:mouseenter="$dispatch('pause-auto-dismiss')" x-on:mouseleave="$dispatch('resume-auto-dismiss')" class="group pointer-events-none fixed inset-x-8 top-0 z-99 flex max-w-full flex-col gap-2 bg-transparent px-6 py-6 md:bottom-0 md:left-[unset] md:right-0 md:top-[unset] md:max-w-sm">
<template x-for="(notification, index) in notifications" x-bind:key="notification.id">
<div>
<!-- Info Notification -->
<template x-if="notification.variant === 'info'">
<div x-data="{ isVisible: false, timeout: null }" x-cloak x-show="isVisible" class="pointer-events-auto relative rounded-radius border border-info bg-surface text-on-surface dark:bg-surface-dark dark:text-on-surface-dark" role="alert" x-on:pause-auto-dismiss.window="clearTimeout(timeout)" x-on:resume-auto-dismiss.window=" timeout = setTimeout(() => {(isVisible = false), removeNotification(notification.id) }, displayDuration)" x-init="$nextTick(() => { isVisible = true }), (timeout = setTimeout(() => { isVisible = false, removeNotification(notification.id)}, displayDuration))" x-transition:enter="transition duration-300 ease-out" x-transition:enter-end="translate-y-0" x-transition:enter-start="translate-y-8" x-transition:leave="transition duration-300 ease-in" x-transition:leave-end="-translate-x-24 opacity-0 md:translate-x-24" x-transition:leave-start="translate-x-0 opacity-100">
<div class="flex w-full items-center gap-2.5 bg-info/10 rounded-radius p-4 transition-all duration-300">
<div class="rounded-full bg-info/15 p-0.5 text-info" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5" aria-hidden="true">
<path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-7-4a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM9 9a.75.75 0 0 0 0 1.5h.253a.25.25 0 0 1 .244.304l-.459 2.066A1.75 1.75 0 0 0 10.747 15H11a.75.75 0 0 0 0-1.5h-.253a.25.25 0 0 1-.244-.304l.459-2.066A1.75 1.75 0 0 0 9.253 9H9Z" clip-rule="evenodd" />
</svg>
<span x-text="t.msg"></span>
</div>
<div class="flex flex-col gap-2">
<h3 x-cloak x-show="notification.title" class="text-sm font-semibold text-info" x-text="notification.title"></h3>
<p x-cloak x-show="notification.message" class="text-pretty text-sm" x-text="notification.message"></p>
</div>
<button type="button" class="ml-auto" aria-label="dismiss notification" x-on:click="(isVisible = false), removeNotification(notification.id)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="2" class="size-5 shrink-0" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
</template>
<!-- Success Notification -->
<template x-if="notification.variant === 'success'">
<div x-data="{ isVisible: false, timeout: null }" x-cloak x-show="isVisible" class="pointer-events-auto relative rounded-radius border border-success bg-surface text-on-surface dark:bg-surface-dark dark:text-on-surface-dark" role="alert" x-on:pause-auto-dismiss.window="clearTimeout(timeout)" x-on:resume-auto-dismiss.window=" timeout = setTimeout(() => {(isVisible = false), removeNotification(notification.id) }, displayDuration)" x-init="$nextTick(() => { isVisible = true }), (timeout = setTimeout(() => { isVisible = false, removeNotification(notification.id)}, displayDuration))" x-transition:enter="transition duration-300 ease-out" x-transition:enter-end="translate-y-0" x-transition:enter-start="translate-y-8" x-transition:leave="transition duration-300 ease-in" x-transition:leave-end="-translate-x-24 opacity-0 md:translate-x-24" x-transition:leave-start="translate-x-0 opacity-100">
<div class="flex w-full items-center gap-2.5 bg-success/10 rounded-radius p-4 transition-all duration-300">
<div class="rounded-full bg-success/15 p-0.5 text-success" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" />
</svg>
</div>
<div class="flex flex-col gap-2">
<h3 x-cloak x-show="notification.title" class="text-sm font-semibold text-success" x-text="notification.title"></h3>
<p x-cloak x-show="notification.message" class="text-pretty text-sm" x-text="notification.message"></p>
</div>
<button type="button" class="ml-auto" aria-label="dismiss notification" x-on:click="(isVisible = false), removeNotification(notification.id)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="2" class="size-5 shrink-0" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
</template>
<!-- Warning Notification -->
<template x-if="notification.variant === 'warning'">
<div x-data="{ isVisible: false, timeout: null }" x-cloak x-show="isVisible" class="pointer-events-auto relative rounded-radius border border-warning bg-surface text-on-surface dark:bg-surface-dark dark:text-on-surface-dark" role="alert" x-on:pause-auto-dismiss.window="clearTimeout(timeout)" x-on:resume-auto-dismiss.window=" timeout = setTimeout(() => {(isVisible = false), removeNotification(notification.id) }, displayDuration)" x-init="$nextTick(() => { isVisible = true }), (timeout = setTimeout(() => { isVisible = false, removeNotification(notification.id)}, displayDuration))" x-transition:enter="transition duration-300 ease-out" x-transition:enter-end="translate-y-0" x-transition:enter-start="translate-y-8" x-transition:leave="transition duration-300 ease-in" x-transition:leave-end="-translate-x-24 opacity-0 md:translate-x-24" x-transition:leave-start="translate-x-0 opacity-100">
<div class="flex w-full items-center gap-2.5 bg-warning/10 rounded-radius p-4 transition-all duration-300">
<div class="rounded-full bg-warning/15 p-0.5 text-warning" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5" aria-hidden="true">
<path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
</svg>
</div>
<div class="flex flex-col gap-2">
<h3 x-cloak x-show="notification.title" class="text-sm font-semibold text-warning" x-text="notification.title"></h3>
<p x-cloak x-show="notification.message" class="text-pretty text-sm" x-text="notification.message"></p>
</div>
<button type="button" class="ml-auto" aria-label="dismiss notification" x-on:click="(isVisible = false), removeNotification(notification.id)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="2" class="size-5 shrink-0" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
</template>
<!-- Danger Notification -->
<template x-if="notification.variant === 'danger'">
<div x-data="{ isVisible: false, timeout: null }" x-cloak x-show="isVisible" class="pointer-events-auto relative rounded-radius border border-danger bg-surface text-on-surface dark:bg-surface-dark dark:text-on-surface-dark" role="alert" x-on:pause-auto-dismiss.window="clearTimeout(timeout)" x-on:resume-auto-dismiss.window=" timeout = setTimeout(() => {(isVisible = false), removeNotification(notification.id) }, displayDuration)" x-init="$nextTick(() => { isVisible = true }), (timeout = setTimeout(() => { isVisible = false, removeNotification(notification.id)}, displayDuration))" x-transition:enter="transition duration-300 ease-out" x-transition:enter-end="translate-y-0" x-transition:enter-start="translate-y-8" x-transition:leave="transition duration-300 ease-in" x-transition:leave-end="-translate-x-24 opacity-0 md:translate-x-24" x-transition:leave-start="translate-x-0 opacity-100">
<div class="flex w-full items-center gap-2.5 bg-danger/10 rounded-radius p-4 transition-all duration-300">
<div class="rounded-full bg-danger/15 p-0.5 text-danger" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5" aria-hidden="true">
<path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
</svg>
</div>
<div class="flex flex-col gap-2">
<h3 x-cloak x-show="notification.title" class="text-sm font-semibold text-danger" x-text="notification.title"></h3>
<p x-cloak x-show="notification.message" class="text-pretty text-sm" x-text="notification.message"></p>
</div>
<button type="button" class="ml-auto" aria-label="dismiss notification" x-on:click="(isVisible = false), removeNotification(notification.id)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="2" class="size-5 shrink-0" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
</template>
<!-- Message Notification -->
<template x-if="notification.variant === 'message'">
<div x-data="{ isVisible: false, timeout: null }" x-cloak x-show="isVisible" class="pointer-events-auto relative rounded-radius border border-outline bg-surface text-on-surface dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark" role="alert" x-on:pause-auto-dismiss.window="clearTimeout(timeout)" x-on:resume-auto-dismiss.window="timeout = setTimeout(() => { isVisible = false, removeNotification(notification.id) }, displayDuration)" x-init="$nextTick(() => { isVisible = true }), (timeout = setTimeout(() => { isVisible = false, removeNotification(notification.id) }, displayDuration))" x-transition:enter="transition duration-300 ease-out" x-transition:enter-end="translate-y-0" x-transition:enter-start="translate-y-8" x-transition:leave="transition duration-300 ease-in" x-transition:leave-end="-translate-x-24 opacity-0 md:translate-x-24" x-transition:leave-start="translate-x-0 opacity-100">
<div class="flex w-full rounded-radius items-center gap-2.5 bg-surface-alt p-4 transition-all duration-300 dark:bg-surface-dark-alt">
<div class="flex w-full items-center gap-2.5">
<img x-cloak x-show="notification.sender.avatar" class="mr-2 size-12 rounded-full" alt="avatar" aria-hidden="true" x-bind:src="notification.sender.avatar"/>
<div class="flex flex-col items-start gap-2">
<h3 x-cloak x-show="notification.sender.name" class="text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong" x-text="notification.sender.name"></h3>
<p x-cloak x-show="notification.message" class="text-pretty text-sm" x-text="notification.message"></p>
<div class="flex items-center gap-4">
<button type="button" class="whitespace-nowrap bg-transparent text-center text-sm font-bold tracking-wide text-primary transition hover:opacity-75 active:opacity-100 dark:text-primary-dark">Reply</button>
<button type="button" class="whitespace-nowrap bg-transparent text-center text-sm font-bold tracking-wide text-on-surface transition hover:opacity-75 active:opacity-100 dark:text-on-surface-dark" x-on:click=" (isVisible = false), setTimeout(() => { removeNotification(notification.id) }, 400)">Dismiss</button>
</div>
</div>
</div>
<button type="button" class="ml-auto" aria-label="dismiss notification" x-on:click="(isVisible = false), removeNotification(notification.id)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="2" class="size-5 shrink-0" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
</template>
</div>
</template>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,204 @@
<!-- Triggers -->
<!-- Message Trigger -->
<button x-on:click="$dispatch('notify', { variant: 'message', sender:{name:'Jack Ellis', avatar:'https://penguinui.s3.amazonaws.com/component-assets/avatar-2.webp'}, message: 'Hey, can you review the PR I just submitted? Let me know if you spot any issues!' })" type="button" class="whitespace-nowrap rounded-radius bg-primary px-4 py-2 text-center text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary active:opacity-100 active:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark dark:focus-visible:outline-primary-dark">Message</button>
<!-- Info Trigger -->
<button x-on:click="$dispatch('notify', { variant: 'info', title: 'Update Available', message: 'A new version of the app is ready for you. Update now to enjoy the latest features!' })" type="button" class="whitespace-nowrap rounded-radius bg-info px-4 py-2 text-center text-sm font-medium tracking-wide text-on-info transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info active:opacity-100 active:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-75">Info</button>
<!-- Success Trigger -->
<button x-on:click="$dispatch('notify', { variant: 'success', title: 'Success!', message: 'Your changes have been saved. Keep up the great work!' })" type="button" class="whitespace-nowrap rounded-radius bg-success px-4 py-2 text-center text-sm font-medium tracking-wide text-on-success transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-success active:opacity-100 active:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-75">Success</button>
<!-- Danger Trigger -->
<button x-on:click="$dispatch('notify', { variant: 'danger', title: 'Oops!', message: 'Something went wrong. Please try again. If the problem persists, were here to help!' })" type="button" class="whitespace-nowrap rounded-radius bg-danger px-4 py-2 text-center text-sm font-medium tracking-wide text-on-danger transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-danger active:opacity-100 active:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-75">Danger</button>
<!-- Warning Trigger -->
<button x-on:click="$dispatch('notify', { variant: 'warning', title: 'Action Needed', message: 'Your storage is getting low. Consider upgrading your plan.' })" type="button" class="whitespace-nowrap rounded-radius bg-warning px-4 py-2 text-center text-sm font-medium tracking-wide text-on-warning transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-warning active:opacity-100 active:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-75">Warning</button>
<!-- Notifications -->
<div x-data="{
notifications: [],
displayDuration: 8000,
soundEffect: false,
addNotification({ variant = 'info', sender = null, title = null, message = null}) {
const id = Date.now()
const notification = { id, variant, sender, title, message }
// Keep only the most recent 20 notifications
if (this.notifications.length >= 20) {
this.notifications.splice(0, this.notifications.length - 19)
}
// Add the new notification to the notifications stack
this.notifications.push(notification)
if (this.soundEffect) {
// Play the notification sound
const notificationSound = new Audio('https://res.cloudinary.com/ds8pgw1pf/video/upload/v1728571480/penguinui/component-assets/sounds/ding.mp3')
notificationSound.play().catch((error) => {
console.error('Error playing the sound:', error)
})
}
},
removeNotification(id) {
setTimeout(() => {
this.notifications = this.notifications.filter(
(notification) => notification.id !== id,
)
}, 400);
},
}" x-on:notify.window="addNotification({
variant: $event.detail.variant,
sender: $event.detail.sender,
title: $event.detail.title,
message: $event.detail.message,
})">
<div x-on:mouseenter="$dispatch('pause-auto-dismiss')" x-on:mouseleave="$dispatch('resume-auto-dismiss')" class="group pointer-events-none fixed inset-x-8 top-0 z-99 flex max-w-full flex-col gap-2 bg-transparent px-6 py-6 md:bottom-0 md:left-[unset] md:right-0 md:top-[unset] md:max-w-sm">
<template x-for="(notification, index) in notifications" x-bind:key="notification.id">
<!-- root div holds all of the notifications -->
<div>
<!-- Info Notification -->
<template x-if="notification.variant === 'info'">
<div x-data="{ isVisible: false, timeout: null }" x-cloak x-show="isVisible" class="pointer-events-auto relative rounded-radius border border-info bg-surface text-on-surface dark:bg-surface-dark dark:text-on-surface-dark" role="alert" x-on:pause-auto-dismiss.window="clearTimeout(timeout)" x-on:resume-auto-dismiss.window=" timeout = setTimeout(() => {(isVisible = false), removeNotification(notification.id) }, displayDuration)" x-init="$nextTick(() => { isVisible = true }), (timeout = setTimeout(() => { isVisible = false, removeNotification(notification.id)}, displayDuration))" x-transition:enter="transition duration-300 ease-out" x-transition:enter-end="translate-y-0" x-transition:enter-start="translate-y-8" x-transition:leave="transition duration-300 ease-in" x-transition:leave-end="-translate-x-24 opacity-0 md:translate-x-24" x-transition:leave-start="translate-x-0 opacity-100">
<div class="flex w-full items-center gap-2.5 bg-info/10 rounded-radius p-4 transition-all duration-300">
<!-- Icon -->
<div class="rounded-full bg-info/15 p-0.5 text-info" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5" aria-hidden="true">
<path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-7-4a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM9 9a.75.75 0 0 0 0 1.5h.253a.25.25 0 0 1 .244.304l-.459 2.066A1.75 1.75 0 0 0 10.747 15H11a.75.75 0 0 0 0-1.5h-.253a.25.25 0 0 1-.244-.304l.459-2.066A1.75 1.75 0 0 0 9.253 9H9Z" clip-rule="evenodd" />
</svg>
</div>
<!-- Title & Message -->
<div class="flex flex-col gap-2">
<h3 x-cloak x-show="notification.title" class="text-sm font-semibold text-info" x-text="notification.title"></h3>
<p x-cloak x-show="notification.message" class="text-pretty text-sm" x-text="notification.message"></p>
</div>
<!--Dismiss Button -->
<button type="button" class="ml-auto" aria-label="dismiss notification" x-on:click="(isVisible = false), removeNotification(notification.id)">
<svg xmlns="http://www.w3.org/2000/svg viewBox="0 0 24 24 stroke="currentColor" fill="none" stroke-width="2" class="size-5 shrink-0" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
</template>
<!-- Success Notification -->
<template x-if="notification.variant === 'success'">
<div x-data="{ isVisible: false, timeout: null }" x-cloak x-show="isVisible" class="pointer-events-auto relative rounded-radius border border-success bg-surface text-on-surface dark:bg-surface-dark dark:text-on-surface-dark" role="alert" x-on:pause-auto-dismiss.window="clearTimeout(timeout)" x-on:resume-auto-dismiss.window=" timeout = setTimeout(() => {(isVisible = false), removeNotification(notification.id) }, displayDuration)" x-init="$nextTick(() => { isVisible = true }), (timeout = setTimeout(() => { isVisible = false, removeNotification(notification.id)}, displayDuration))" x-transition:enter="transition duration-300 ease-out" x-transition:enter-end="translate-y-0" x-transition:enter-start="translate-y-8" x-transition:leave="transition duration-300 ease-in" x-transition:leave-end="-translate-x-24 opacity-0 md:translate-x-24" x-transition:leave-start="translate-x-0 opacity-100">
<div class="flex w-full items-center gap-2.5 bg-success/10 rounded-radius p-4 transition-all duration-300">
<!-- Icon -->
<div class="rounded-full bg-success/15 p-0.5 text-success" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" />
</svg>
</div>
<!-- Title & Message -->
<div class="flex flex-col gap-2">
<h3 x-cloak x-show="notification.title" class="text-sm font-semibold text-success" x-text="notification.title"></h3>
<p x-cloak x-show="notification.message" class="text-pretty text-sm" x-text="notification.message"></p>
</div>
<!--Dismiss Button -->
<button type="button" class="ml-auto" aria-label="dismiss notification" x-on:click="(isVisible = false), removeNotification(notification.id)">
<svg xmlns="http://www.w3.org/2000/svg viewBox="0 0 24 24 stroke="currentColor" fill="none" stroke-width="2" class="size-5 shrink-0" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
</template>
<!-- Warning Notification -->
<template x-if="notification.variant === 'warning'">
<div x-data="{ isVisible: false, timeout: null }" x-cloak x-show="isVisible" class="pointer-events-auto relative rounded-radius border border-warning bg-surface text-on-surface dark:bg-surface-dark dark:text-on-surface-dark" role="alert" x-on:pause-auto-dismiss.window="clearTimeout(timeout)" x-on:resume-auto-dismiss.window=" timeout = setTimeout(() => {(isVisible = false), removeNotification(notification.id) }, displayDuration)" x-init="$nextTick(() => { isVisible = true }), (timeout = setTimeout(() => { isVisible = false, removeNotification(notification.id)}, displayDuration))" x-transition:enter="transition duration-300 ease-out" x-transition:enter-end="translate-y-0" x-transition:enter-start="translate-y-8" x-transition:leave="transition duration-300 ease-in" x-transition:leave-end="-translate-x-24 opacity-0 md:translate-x-24" x-transition:leave-start="translate-x-0 opacity-100">
<div class="flex w-full items-center gap-2.5 bg-warning/10 rounded-radius p-4 transition-all duration-300">
<!-- Icon -->
<div class="rounded-full bg-warning/15 p-0.5 text-warning" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5" aria-hidden="true">
<path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
</svg>
</div>
<!-- Title & Message -->
<div class="flex flex-col gap-2">
<h3 x-cloak x-show="notification.title" class="text-sm font-semibold text-warning" x-text="notification.title"></h3>
<p x-cloak x-show="notification.message" class="text-pretty text-sm" x-text="notification.message"></p>
</div>
<!--Dismiss Button -->
<button type="button" class="ml-auto" aria-label="dismiss notification" x-on:click="(isVisible = false), removeNotification(notification.id)">
<svg xmlns="http://www.w3.org/2000/svg viewBox="0 0 24 24 stroke="currentColor" fill="none" stroke-width="2" class="size-5 shrink-0" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
</template>
<!-- Danger Notification -->
<template x-if="notification.variant === 'danger'">
<div x-data="{ isVisible: false, timeout: null }" x-cloak x-show="isVisible" class="pointer-events-auto relative rounded-radius border border-danger bg-surface text-on-surface dark:bg-surface-dark dark:text-on-surface-dark" role="alert" x-on:pause-auto-dismiss.window="clearTimeout(timeout)" x-on:resume-auto-dismiss.window=" timeout = setTimeout(() => {(isVisible = false), removeNotification(notification.id) }, displayDuration)" x-init="$nextTick(() => { isVisible = true }), (timeout = setTimeout(() => { isVisible = false, removeNotification(notification.id)}, displayDuration))" x-transition:enter="transition duration-300 ease-out" x-transition:enter-end="translate-y-0" x-transition:enter-start="translate-y-8" x-transition:leave="transition duration-300 ease-in" x-transition:leave-end="-translate-x-24 opacity-0 md:translate-x-24" x-transition:leave-start="translate-x-0 opacity-100">
<div class="flex w-full items-center gap-2.5 bg-danger/10 rounded-radius p-4 transition-all duration-300">
<!-- Icon -->
<div class="rounded-full bg-danger/15 p-0.5 text-danger" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5" aria-hidden="true">
<path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
</svg>
</div>
<!-- Title & Message -->
<div class="flex flex-col gap-2">
<h3 x-cloak x-show="notification.title" class="text-sm font-semibold text-danger" x-text="notification.title"></h3>
<p x-cloak x-show="notification.message" class="text-pretty text-sm" x-text="notification.message"></p>
</div>
<!--Dismiss Button -->
<button type="button" class="ml-auto" aria-label="dismiss notification" x-on:click="(isVisible = false), removeNotification(notification.id)">
<svg xmlns="http://www.w3.org/2000/svg viewBox="0 0 24 24 stroke="currentColor" fill="none" stroke-width="2" class="size-5 shrink-0" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
</template>
<!-- Message Notification -->
<template x-if="notification.variant === 'message'">
<div x-data="{ isVisible: false, timeout: null }" x-cloak x-show="isVisible" class="pointer-events-auto relative rounded-radius border border-outline bg-surface text-on-surface dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark" role="alert" x-on:pause-auto-dismiss.window="clearTimeout(timeout)" x-on:resume-auto-dismiss.window="timeout = setTimeout(() => { isVisible = false, removeNotification(notification.id) }, displayDuration)" x-init="$nextTick(() => { isVisible = true }), (timeout = setTimeout(() => { isVisible = false, removeNotification(notification.id) }, displayDuration))" x-transition:enter="transition duration-300 ease-out" x-transition:enter-end="translate-y-0" x-transition:enter-start="translate-y-8" x-transition:leave="transition duration-300 ease-in" x-transition:leave-end="-translate-x-24 opacity-0 md:translate-x-24" x-transition:leave-start="translate-x-0 opacity-100">
<div class="flex w-full rounded-radius items-center gap-2.5 bg-surface-alt p-4 transition-all duration-300 dark:bg-surface-dark-alt">
<div class="flex w-full items-center gap-2.5">
<!-- Avatar -->
<img x-cloak x-show="notification.sender.avatar" class="mr-2 size-12 rounded-full" alt="avatar" aria-hidden="true" x-bind:src="notification.sender.avatar"/>
<div class="flex flex-col items-start gap-2">
<!-- Title & Message -->
<h3 x-cloak x-show="notification.sender.name" class="text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong" x-text="notification.sender.name"></h3>
<p x-cloak x-show="notification.message" class="text-pretty text-sm" x-text="notification.message"></p>
<!-- Action Buttons -->
<div class="flex items-center gap-4">
<button type="button" class="whitespace-nowrap bg-transparent text-center text-sm font-bold tracking-wide text-primary transition hover:opacity-75 active:opacity-100 dark:text-primary-dark">Reply</button>
<button type="button" class="whitespace-nowrap bg-transparent text-center text-sm font-bold tracking-wide text-on-surface transition hover:opacity-75 active:opacity-100 dark:text-on-surface-dark" x-on:click=" (isVisible = false), setTimeout(() => { removeNotification(notification.id) }, 400)">Dismiss</button>
</div>
</div>
</div>
<!-- Dismiss Button -->
<button type="button" class="ml-auto" aria-label="dismiss notification" x-on:click="(isVisible = false), removeNotification(notification.id)">
<svg xmlns="http://www.w3.org/2000/svg viewBox="0 0 24 24 stroke="currentColor" fill="none" stroke-width="2" class="size-5 shrink-0" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
</template>
</div>
</template>
</div>
</div>

486
hardcoded-inventory.md Normal file
View File

@@ -0,0 +1,486 @@
# Handcoded UI Components — Penguin UI Replacement Index
> **Scope**: Every handcoded UI component.
> Each item maps to a [Penguin UI](https://github.com/SalarHoushvand/penguinui-components/tree/main) component that duplicates the same purpose with fewer lines and better accessibility.
## Vendoring convention
When a Penguin UI component can replace a handcoded one, we vendor its source
and then use it (instead of hand-rolling):
1. Copy the component's source **byte-for-byte** from the [Penguin UI repo](https://github.com/SalarHoushvand/penguinui-components/tree/main)
into `assets/views/penguinui/`, **mirroring the upstream repo hierarchy**
(e.g. `toast-notification/stacking-toast-notification.html`). This directory
is reserved exclusively for vendored Penguin UI components and is kept an
**exact, unmodified mirror** of upstream — demo triggers, bugs and all. It's
a reference, not the rendered markup.
2. Adapt it where it's actually used (strip docs-only demo triggers, fix obvious
upstream bugs, wire data bindings). Note the deviations in a comment next to
the adapted copy.
3. Rebuild Tailwind (`make css`) so any new utility classes get compiled.
4. Mark the section below as ✅ **DONE**.
---
## 0. Toast — ✅ DONE
**Penguin UI: `toast-notification/stacking-toast-notification.html`**
- Exact upstream mirror at `assets/views/penguinui/toast-notification/stacking-toast-notification.html` (reference only)
- Adapted/rendered copy lives inline in `assets/views/base.html` (demo triggers
removed; the upstream dismiss-button `<svg>` quote bugs fixed)
- The global `toast('message')` JS helper now dispatches the component's
`notify` event (`{ variant: 'success', message }`), so existing callsites
(`shop/show.html`, `shop/_card.html`) keep working unchanged.
---
## 1. Navbar
**Penguin UI: `navbar/`**
| # | 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 |
| 2 | `assets/views/admin/base.html:102-114` | Admin top bar: hamburger toggle + breadcrumb text | ~13 lines |
**Details for #1 (site navbar):**
- **Brand/logo**: `base.html:74-77` — plain `<a>` with text
- **Desktop nav links**: `base.html:80-92``<ul>` with 45 items, manual `aria-current` routing
- **Cart icon + badge**: `base.html:96-109` — hand-rolled SVG cart icon + an Alpine `x-data` badge that reads `document.cookie` directly
- **Settings dropdown**: `base.html:110-162` — gear-icon trigger + language-switcher `<form>` + theme tristate (system/light/dark)
- **Mobile hamburger**: `base.html:164-172` — hamburger SVG button
- **Mobile menu panel**: `base.html:175-190` — dropdown `<ul>` with duplicated nav links
**Penguin navbar variants:** `default-navbar.html`, `with-call-to-action.html`, `with-search.html`, `with-user-profile.html`
---
## 2. Sidebar (Admin)
**Penguin UI: `sidebar/` (6 variants)**
| # | Location | What it is | Size |
|---|----------|------------|------|
| 3 | `assets/views/admin/base.html:56-98` | Admin fixed sidebar: 5 nav links (`aria-current`), bottom section (exit + logout button) | ~43 lines |
| 4 | `assets/views/admin/base.html:51-53` | Dark overlay behind sidebar on mobile (`x-show`, `x-transition.opacity`) | ~3 lines |
**Details for #3:**
- 60-column fixed left rail with CSS translate-X show/hide on mobile
- Nav links: Dashboard, Products, Categories, Orders, Shipping
- Each link has `data-nav` + manual `aria-current` logic
- Bottom section: "Exit to shop" link + logout `<form>` with danger button
- Inline chevron SVG for sidebar toggle (hamburger icon at `admin/base.html:106-108`)
---
## 3. Sidebar (Category Accordion)
**Penguin UI: `sidebar/`**
| # | Location | What it is | Size |
|---|----------|------------|------|
| 5 | `assets/views/shop/_sidebar.html:1-60` | Persistent category sidebar/accordion with expandable groups, tree indentation, and active-state routing | ~60 lines |
| 6 | `assets/views/base.html:195-196` | Dark overlay behind category drawer on mobile | ~2 lines |
**Details for #5:**
- "All Products" link at top
- Expandable parent categories (chevron rotates via Alpine `x-data="{ open: false }"`)
- Child categories indented with `padding-left: 28px` + `↳` arrow
- Every link has `data-nav` for the client-side `markActiveNav()` function
- Empty-state fallback paragraph
- The `<aside>` container in `base.html:202-206` uses `hx-preserve` and `hx-get` to load this partial
---
## 4. Dropdown (Settings)
**Penguin UI: `dropdown-menu/` (dropdown-with-click.html, dropdown-with-icons.html)**
| # | Location | What it is | Size |
|---|----------|------------|------|
| 7 | `assets/views/base.html:110-162` | Language + theme settings dropdown (gear icon trigger) | ~53 lines |
| 8 | `assets/views/admin/base.html:117-166` | **100% duplicate** of the same dropdown | ~50 lines |
**Details:**
- Both dropdowns are **identical** (copy-paste), totaling ~103 lines of duplicated code
- Alpine `x-data="{ open: false }"` + `@click.outside`
- Language switcher: English / Slovenčina buttons in a `<form method="post" action="/lang">`
- Theme tristate: system / light / dark with `setTheme()` and `currentTheme()` from inline JS
- Gear cog inline SVG icon
---
## 5. Country / Phone Combobox
**Penguin UI: `text-input/` + custom dropdown list**
| # | Location | What it is | Size |
|---|----------|------------|------|
| 9 | `assets/views/shop/checkout.html:49-74` | Phone prefix combobox (`+421`, `+420`, …, `+33`) | ~25 lines |
| 10 | `assets/views/shop/checkout.html:102-127` | Country combobox (SK, CZ, AT, DE, PL, HU) | ~26 lines |
**Details for #9:**
- Alpine `x-data` with `prefix`, `prefixOpen`, `opts` array of `{ v, l }` (9 country codes)
- Manual `filtered` computed property
- Inline chevron SVG that rotates via `:class="prefixOpen && 'rotate-180'"`
- Dropdown list with `<template x-for>` and `@click` selection
**Details for #10:**
- Same pattern as #9 but with translate-able country names (6 countries)
- Includes `+421` prefix shortcut
---
## 6. Product Card
**Penguin UI: `card/ecommerce-product-card.html`**
| # | Location | What it is | Size |
|---|----------|------------|------|
| 11 | `assets/views/shop/_card.html:1-30` | E-commerce product card: image, name, price, stock, add-to-cart button | ~30 lines |
**Details:**
- `aspect-square` image container with `group-hover:scale-105` zoom effect
- Product name + price in a flex column
- Bottom section: conditional stock text OR out-of-stock badge + `<form>` with htmx `hx-post` add-to-cart
- Inline toast call on successful add: `hx-on::after-request="... toast()"` (toast excluded per scope)
---
## 7. Product Image Gallery
**Penguin UI: `carousel/` (3 variants)**
| # | 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
---
## 8. Radio-Button Groups
**Penguin UI: radio (part of form inputs)**
| # | 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
---
## 9. Checkbox
**Penguin UI: `checkbox/` (3 variants)**
| # | Location | What it is | Size |
|---|----------|------------|------|
| 15 | `assets/views/admin/catalog/product_form.html:85-89` | "Published" checkbox | ~5 lines |
| 16 | `assets/views/admin/catalog/category_form.html:67-71` | "Published" checkbox | ~5 lines |
| 17 | `assets/views/admin/shipping/index.html:25-29` | "Enabled" checkbox | ~5 lines |
---
## 10. Text Input
**Penguin UI: `text-input/` (8 variants)**
| # | Location | What it is | Size |
|---|----------|------------|------|
| 18 | `assets/views/shop/checkout.html:37-44` | Email + name text inputs | ~8 lines |
| 19 | `assets/views/shop/checkout.html:84-99` | Address, city, ZIP text inputs | ~16 lines |
| 20 | `assets/views/admin/login.html:34-51` | Email + password inputs (with focus ring styles) | ~18 lines |
| 21 | `assets/views/admin/catalog/product_form.html:19-68` | Name, price, currency, stock, SKU, slug inputs + textarea | ~50 lines |
| 22 | `assets/views/admin/catalog/category_form.html:19-55` | Name, slug, position inputs + textarea | ~37 lines |
| 23 | `assets/views/shop/show.html:46-48` | Quantity number input | ~3 lines |
| 24 | `assets/views/shop/_cart_body.html:30-38` | Quantity number input with `@change` confirmation dialog | ~9 lines |
| 25 | `assets/views/admin/shipping/index.html:20-24` | Price text input | ~5 lines |
**Pattern: Every input is hand-styled with:**
```
w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface
focus:outline-2 focus:outline-primary
dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark
```
This exact class string appears 15+ times across the codebase.
---
## 11. Textarea
**Penguin UI: `textarea/`**
| # | Location | What it is | Size |
|---|----------|------------|------|
| 26 | `assets/views/shop/checkout.html:183-186` | Order note textarea | ~4 lines |
| 27 | `assets/views/admin/catalog/product_form.html:71-73` | Product description textarea | ~3 lines |
| 28 | `assets/views/admin/catalog/category_form.html:53-55` | Category description textarea | ~3 lines |
---
## 12. Select/Dropdown (Native)
**Penguin UI: `select/` (7 variants)**
| # | Location | What it is | Size |
|---|----------|------------|------|
| 29 | `assets/views/admin/catalog/product_form.html:53-60` | Category select | ~8 lines |
| 30 | `assets/views/admin/catalog/category_form.html:40-49` | Parent category select (tree indented with `—&nbsp;`) | ~10 lines |
| 31 | `assets/views/admin/orders/show.html:108-112` | Order status select | ~5 lines |
---
## 13. File Input
**Penguin UI: `file-input/`**
| # | Location | What it is | Size |
|---|----------|------------|------|
| 32 | `assets/views/admin/catalog/product_form.html:78-82` | Product image upload | ~5 lines |
| 33 | `assets/views/admin/catalog/category_form.html:58-64` | Category image upload | ~7 lines |
**Both use the same Tailwind `file:mr-3 file:...` prefix pattern for styling.**
---
## 14. Table
**Penguin UI: `table/` (7 variants)**
| # | Location | What it is | Size |
|---|----------|------------|------|
| 34 | `assets/views/admin/orders/index.html:11-36` | Orders table: number, customer, status pill, total, "View" link | ~26 lines |
| 35 | `assets/views/admin/orders/show.html:20-44` | Order items table: product, quantity, line total + tfoot summary | ~25 lines |
| 36 | `assets/views/admin/catalog/products.html:20-70` | Products table: image+name+category, price, stock, status pill, edit/view/delete actions | ~51 lines |
| 37 | `assets/views/admin/catalog/categories.html:20-59` | Categories table: tree-indented name, product count, status pill, edit/delete | ~40 lines |
| 38 | `assets/views/shop/_cart_body.html:6-59` | Cart table: product link, price, quantity input, line total, remove button + tfoot total | ~54 lines |
**Pattern:** Every table uses the same class structure:
```
<table class="w-full text-left text-sm">
<thead class="border-b border-outline bg-surface-alt text-xs uppercase tracking-wide text-on-surface/70">
<tbody class="divide-y divide-outline">
<tr class="hover:bg-surface-alt">
```
This is copy-pasted 5 times.
---
## 15. Alert / Error Banner
**Penguin UI: `alert/` (6 variants)**
| # | Location | What it is | Size |
|---|----------|------------|------|
| 39 | `assets/views/admin/login.html:25-30` | Login error alert (`role="alert"`, danger border) | ~6 lines |
| 40 | `assets/views/admin/orders/show.html:13-15` | Ship error alert (danger border) | ~3 lines |
**Pattern:** `rounded-radius border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger`
---
## 16. Badge / Status Pill
**Penguin UI: `badge/` (7 variants)**
| # | Location | What it is | Size |
|---|----------|------------|------|
| 41 | `assets/views/base.html:107-108` | Cart item-count badge (absolute-positioned, number) | ~2 lines |
| 42 | `assets/views/admin/login.html:14-16` | "Auth" badge on login card header | ~3 lines |
| 43 | `assets/views/admin/orders/index.html:27` | Order status inline pill | ~1 line |
| 44 | `assets/views/admin/catalog/products.html:49-53` | Published/Draft status pill | ~5 lines |
| 45 | `assets/views/admin/catalog/categories.html:40-44` | Published/Draft status pill | ~5 lines |
| 46 | `assets/views/shop/_card.html:27` | "Out of stock" badge (danger background) | ~1 line |
---
## 17. Buttons
**Penguin UI: `buttons/` (6 variants)**
| # | Location | What it is | Style | Count |
|---|----------|------------|-------|-------|
| 47 | Across all templates | Primary button | `bg-primary text-on-primary rounded-radius px-4 py-2` | 15+ |
| 48 | Across all templates | Outline/secondary button | `border border-outline text-on-surface rounded-radius px-3 py-1.5` | 20+ |
| 49 | `base.html:86-88`, `admin/base.html:92-95` | Danger button (logout, delete) | `text-danger` with hover bg | 4 |
| 50 | `base.html:84` | Warning-colored button (admin link) | `text-warning` | 1 |
| 51 | `base.html:112-113`, `admin/base.html:118-119`, ... | Icon-only ghost button (gear, hamburger, chevron) | `size-9 inline-flex items-center justify-center` | 10+ |
| 52 | `_cart_body.html:46` | Text/link button (Remove) | `text-danger hover:underline` | 1 |
Summary of button variants handcoded:
- `bg-primary` (solid primary)
- `border border-outline` (outline)
- `file:bg-primary` (file input button)
- `text-danger` / `text-warning` (semantic)
- `hover:opacity-75` vs `hover:opacity-90` — inconsistent hover effects
- `tracking-wide` on some, not others
---
## 18. Toggle / Switch
**Penguin UI: `toggle/` (2 variants)**
| # | Location | What it is | Size |
|---|----------|------------|------|
| 53 | `assets/views/base.html:13-30` | Theme toggle (dark/light/system) — inline `<script>` JavaScript | ~18 lines |
| 54 | `assets/views/admin/base.html:13-30` | **Exact duplicate** of the theme toggle JS | ~18 lines |
**Details:**
- `applyTheme()`, `setTheme()`, `currentTheme()` — reads/writes `localStorage`
- `matchMedia('prefers-color-scheme: dark')` listener
- All hand-written vanilla JS, duplicated twice (36 lines total)
---
## 19. Inline SVG Icons
**Penguin UI: none (Penguin uses Heroicons-equivalent inline SVGs)**
| # | Location | Icon | Occurrences |
|---|----------|------|-------------|
| 55 | `base.html:70-72,168-170` | Hamburger (3-line menu) | 2 |
| 56 | `base.html:104-105` | Shopping cart | 1 |
| 57 | `base.html:116-121` | Gear/cog (settings) | 1 |
| 58 | ~~`base.html:220-221`~~ | Checkmark (toast success) | ✅ removed — now in vendored toast component |
| 59 | `checkout.html:62-64,115-117` | Chevron-down (dropdown arrow) | 2 |
| 60 | `_sidebar.html:30-33` | Chevron-right (accordion expand) | 1 |
| 61 | `admin/base.html:106-108` | Hamburger (admin sidebar toggle) | 1 |
| 62 | `admin/base.html:121-125` | Gear/cog (admin settings) | 1 |
All are raw inline `<svg>` with hardcoded `<path d="...">` — no icon library, no partials.
---
## 20. Empty State
**Penguin UI: no direct component, but table empty states exist in Penguin tables**
| # | Location | What it is | Size |
|---|----------|------------|------|
| 63 | `assets/views/admin/orders/index.html:38-39` | "No orders" message | ~2 lines |
| 64 | `assets/views/admin/catalog/products.html:72-78` | "No products" with CTA button | ~7 lines |
| 65 | `assets/views/admin/catalog/categories.html:61-67` | "No categories" with CTA button | ~7 lines |
| 66 | `assets/views/shop/_cart_body.html:67-70` | "Cart empty" with CTA button | ~4 lines |
| 67 | `assets/views/shop/_sidebar.html:58-59` | "No categories" message | ~2 lines |
---
## 21. Dashboard Navigation Cards
**Penguin UI: `card/`**
| # | Location | What it is | Size |
|---|----------|------------|------|
| 68 | `assets/views/admin/index.html:12-27` | 3 dashboard link cards (Products, Categories, Orders) | ~16 lines |
**Details:**
- Each card is an `<a>` styled with border, hover effect, and nested title+description
- Same hover pattern: `hover:border-primary`
---
## 22. Checkout Order Summary
**Penguin UI: `card/` (ecommerce-summary style)**
| # | Location | What it is | Size |
|---|----------|------------|------|
| 69 | `assets/views/shop/checkout.html:190-218` | Cart summary aside: item list, subtotal, shipping, total, place-order button | ~29 lines |
**Details:**
- Item list with name × quantity + line total
- Subtotal + shipping + total with `tabular-nums`
- Dynamic shipping price from Alpine `carrierPrice`
- Disabled submit button when `!canSubmit`
---
## 23. Login Card
**Penguin UI: `card/`**
| # | Location | What it is | Size |
|---|----------|------------|------|
| 70 | `assets/views/admin/login.html:6-61` | Full login form: header with auth badge, email + password inputs, error alert, submit button | ~56 lines |
---
## 24. Checkout Fieldset Cards
**Penguin UI: `card/`**
| # | Location | What it is | Size |
|---|----------|------------|------|
| 71 | `assets/views/shop/checkout.html:34-79` | Contact info fieldset (email, name, phone+prefix) | ~46 lines |
| 72 | `assets/views/shop/checkout.html:82-130` | Shipping address fieldset (address, city, zip, country) | ~49 lines |
| 73 | `assets/views/shop/checkout.html:133-165` | Carrier selection fieldset | ~33 lines |
| 74 | `assets/views/shop/checkout.html:167-180` | Payment method fieldset | ~14 lines |
Each fieldset uses `<fieldset>` + `<legend>` with the same `rounded-radius border border-outline bg-surface p-6` styling.
---
## 25. Order Detail Info Panel
**Penguin UI: `card/`**
| # | Location | What it is | Size |
|---|----------|------------|------|
| 75 | `assets/views/admin/orders/show.html:49-77` | Customer + shipping + payment info panel | ~29 lines |
| 76 | `assets/views/admin/orders/show.html:79-103` | Fulfillment panel (tracking, label link, ship button) | ~25 lines |
| 77 | `assets/views/admin/orders/show.html:106-115` | Status update form panel | ~10 lines |
---
## 26. Shipping Method Settings Row
**Penguin UI: `card/`**
| # | 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
**Penguin UI: `card/`**
| # | 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 |
| 80 | `assets/views/admin/catalog/category_form.html:15-81` | Full category edit/create form with all fields | ~66 lines |
Both are wrapped in a single card-style `<form>`.
---
## 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 |
**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`