refactor: remove DaisyUI and implement custom theme system

- Remove DaisyUI dependency from package.json
- Implement custom CSS components (btn, badge, navbar, menu, card, footer)
- Add dark/light theme toggle with localStorage persistence
- Update color scheme to dark blue palette (primary: blue, secondary: cyan, accent: electric blue)
- Make blog pages full-width by removing max-width constraints
- Restore sidebar navigation for clqms posts with full-width layout
- Fix navbar menu alignment issues with flexbox

BREAKING CHANGE: DaisyUI classes replaced with custom implementations
This commit is contained in:
mahdahar 2026-01-07 11:21:35 +07:00
parent b3a1323368
commit ec37ccc9bb
12 changed files with 1318 additions and 95 deletions

1
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1 @@
{}

11
package-lock.json generated
View File

@ -11,7 +11,6 @@
"devDependencies": { "devDependencies": {
"@11ty/eleventy": "^3.1.2", "@11ty/eleventy": "^3.1.2",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"daisyui": "^5.5.14",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"postcss-cli": "^11.0.1", "postcss-cli": "^11.0.1",
@ -1073,16 +1072,6 @@
"semver": "bin/semver" "semver": "bin/semver"
} }
}, },
"node_modules/daisyui": {
"version": "5.5.14",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.14.tgz",
"integrity": "sha512-L47rvw7I7hK68TA97VB8Ee0woHew+/ohR6Lx6Ah/krfISOqcG4My7poNpX5Mo5/ytMxiR40fEaz6njzDi7cuSg==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/saadeghi/daisyui?sponsor=1"
}
},
"node_modules/data-view-buffer": { "node_modules/data-view-buffer": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",

View File

@ -1,7 +1,7 @@
{ {
"name": "5panda.11ty", "name": "5panda.11ty",
"version": "1.0.0", "version": "1.0.0",
"description": "5Panda Portfolio, Blog & Documentation site built with 11ty, Tailwind CSS v4 and daisyUI v5", "description": "5Panda Portfolio, Blog & Documentation site built with 11ty and Tailwind CSS v4",
"scripts": { "scripts": {
"dev": "npm-run-all --parallel dev:*", "dev": "npm-run-all --parallel dev:*",
"dev:11ty": "eleventy --serve", "dev:11ty": "eleventy --serve",
@ -15,15 +15,13 @@
"blog", "blog",
"docs", "docs",
"11ty", "11ty",
"tailwindcss", "tailwindcss"
"daisyui"
], ],
"author": "5Panda", "author": "5Panda",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@11ty/eleventy": "^3.1.2", "@11ty/eleventy": "^3.1.2",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"daisyui": "^5.5.14",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"postcss-cli": "^11.0.1", "postcss-cli": "^11.0.1",

View File

@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-theme="panda"> <html lang="en" data-theme="dark">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@ -16,7 +16,19 @@
<link <link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"> <!-- Styles --> rel="stylesheet"> <!-- Styles -->
<link rel="stylesheet" href="/css/style.css"> <link
rel="stylesheet" href="/css/style.css">
<!-- Theme initialization script (must be in head to prevent FOUC) -->
<script>
(function () {
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
document.documentElement.setAttribute('data-theme', savedTheme);
} else if (window.matchMedia('(prefers-color-scheme: light)').matches) {
document.documentElement.setAttribute('data-theme', 'light');
}
})();
</script>
</head> </head>
<body <body
class="min-h-screen bg-base-100 text-base-content"> class="min-h-screen bg-base-100 text-base-content">
@ -58,7 +70,7 @@
</summary> </summary>
<ul class="p-2 bg-base-100 rounded-t-none bg-base-100/95 backdrop-blur-xl border border-white/5 shadow-xl w-60 z-[100]"> <ul class="p-2 bg-base-100 rounded-t-none bg-base-100/95 backdrop-blur-xl border border-white/5 shadow-xl w-60 z-[100]">
{% for post in collections.posts %} {% for post in collections.posts %}
{% if 'clqms' not in post.data.tags %} {% if post.data.tags and 'clqms' not in post.data.tags %}
<li> <li>
<a href="{{ post.url }}" class="{% if page.url == post.url %}text-primary bg-primary/10{% endif %} hover:text-primary hover:bg-primary/10"> <a href="{{ post.url }}" class="{% if page.url == post.url %}text-primary bg-primary/10{% endif %} hover:text-primary hover:bg-primary/10">
{{ post.data.title }} {{ post.data.title }}
@ -74,15 +86,14 @@
<div <div
class="navbar-end"> class="navbar-end">
<!-- Theme toggle --> <!-- Theme toggle -->
<label class="swap swap-rotate btn btn-ghost btn-circle btn-sm"> <button id="theme-toggle" class="btn btn-ghost btn-circle btn-sm theme-toggle" aria-label="Toggle theme">
<input type="checkbox" class="theme-controller" value="pandaLight"/> <svg class="icon-sun h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24">
<svg class="swap-off h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24">
<path d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z"/> <path d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z"/>
</svg> </svg>
<svg class="swap-on h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24"> <svg class="icon-moon h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24">
<path d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z"/> <path d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z"/>
</svg> </svg>
</label> </button>
<!-- GitHub link --> <!-- GitHub link -->
<a href="https://github.com" target="_blank" class="btn btn-ghost btn-circle btn-sm"> <a href="https://github.com" target="_blank" class="btn btn-ghost btn-circle btn-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="currentColor" viewbox="0 0 24 24"> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="currentColor" viewbox="0 0 24 24">
@ -128,8 +139,20 @@
</div> </div>
</nav> </nav>
<aside> <aside>
<p class="text-sm text-base-content/40">&copy; {% year %} 5Panda. Built with 11ty, Tailwind CSS & daisyUI.</p> <p class="text-sm text-base-content/40">&copy; {% year %} 5Panda. Built with 11ty & Tailwind CSS.</p>
</aside> </aside>
</footer> </footer>
<!-- Theme toggle script -->
<script>
document.getElementById('theme-toggle').addEventListener('click', function () {
const html = document.documentElement;
const currentTheme = html.getAttribute('data-theme');
const newTheme = currentTheme === 'light'
? 'dark'
: 'light';
html.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
});
</script>
</body> </body>
</html> </html>

View File

@ -2,11 +2,10 @@
layout: base.njk layout: base.njk
--- ---
<div class="section-container py-12 animate-slide-up"> <div class="w-full px-4 sm:px-6 lg:px-8 py-12 animate-slide-up">
<div <div class="flex flex-col lg:flex-row gap-12">
class="flex flex-col lg:flex-row gap-12">
<!-- Sidebar Navigation --> <!-- Sidebar Navigation -->
<aside class="lg:w-1/4"> <aside class="lg:w-64 flex-shrink-0">
<div class="sticky top-24 bg-base-200/50 backdrop-blur-xl border border-white/5 rounded-2xl p-6"> <div class="sticky top-24 bg-base-200/50 backdrop-blur-xl border border-white/5 rounded-2xl p-6">
<h3 class="font-bold text-lg mb-4 flex items-center gap-2"> <h3 class="font-bold text-lg mb-4 flex items-center gap-2">
<svg <svg
@ -41,7 +40,7 @@ layout: base.njk
</div> </div>
</aside> </aside>
<!-- Main Content --> <!-- Main Content -->
<main class="lg:w-3/4 min-w-0"> <main class="flex-1 min-w-0">
<article> <article>
<!-- Post header --> <!-- Post header -->
<header class="mb-10"> <header class="mb-10">

View File

@ -1,8 +1,9 @@
--- ---
layout: base.njk layout: base.njk
--- ---
<article class="section-container py-12 animate-slide-up"> <article class="section-container py-12 animate-slide-up">
<div class="max-w-3xl mx-auto"> <div>
<!-- Post header --> <!-- Post header -->
<header class="mb-10"> <header class="mb-10">
<div class="flex flex-wrap gap-2 mb-4"> <div class="flex flex-wrap gap-2 mb-4">
@ -16,29 +17,31 @@ layout: base.njk
<div class="flex flex-wrap items-center gap-4 text-base-content/60"> <div class="flex flex-wrap items-center gap-4 text-base-content/60">
<time datetime="{{ date | dateFormat('iso') }}" class="flex items-center gap-2"> <time datetime="{{ date | dateFormat('iso') }}" class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg> </svg>
{{ date | dateFormat('full') }} {{ date | dateFormat('full') }}
</time> </time>
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg> </svg>
{{ content | readingTime }} {{ content | readingTime }}
</span> </span>
</div> </div>
</header> </header>
<!-- Post content --> <!-- Post content -->
<div class="prose-custom max-w-none"> <div class="prose-custom max-w-none">
{{ content | safe }} {{ content | safe }}
</div> </div>
<!-- Post footer --> <!-- Post footer -->
<footer class="mt-12 pt-8 border-t border-white/10"> <footer class="mt-12 pt-8 border-t border-white/10">
<a href="/blog/" class="btn btn-outline btn-primary gap-2"> <a href="/blog/" class="btn btn-outline btn-primary gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg> </svg>
Back to Blog Back to Blog
</a> </a>

View File

@ -0,0 +1,563 @@
---
title: "Database Design Roast: Claude Opus"
description: "A professional roast of the CLQMS database schema."
date: 2025-12-12
order: 6
tags:
- posts
- clqms
layout: clqms-post.njk
---
# 🔥 CLQMS Database Schema: A Professional Roast 🔥
> *"I've seen spaghetti code before, but this is spaghetti architecture."*
---
## Executive Summary
After reviewing the CLQMS (Clinical Laboratory Quality Management System) database schema, I regret to inform you that this is not a database design — it's a **crime scene**. Someone read *"Design Patterns for Dummies"* and decided to implement ALL of them. Simultaneously. Incorrectly.
Let me walk you through this masterpiece of over-engineering.
---
## 1. 🗑️ The ValueSet Anti-Pattern: Where Enums Go to Die
### What's Happening
```
valueset → VID, SiteID, VSetID, VValue, VDesc, VCategory
valuesetdef → VSetID, VSName, VSDesc
```
**Every. Single. Enum.** In the entire system is crammed into ONE table:
- Gender
- Country
- Religion
- Ethnicity
- Marital Status
- Death Indicator
- Location Type
- Test Type
- Site Type
- Site Class
- And probably what you had for lunch
### Why This Is Catastrophic
1. **Zero Type Safety**
```sql
-- This is totally valid and will execute without error:
UPDATE patient SET Gender =
(SELECT VID FROM valueset WHERE VDesc = 'Hospital' AND VSetID = 'LocationType')
```
Congratulations, your patient is now a hospital.
2. **The Join Apocalypse**
Getting a single patient with readable values requires this:
```php
->join('valueset country', 'country.VID = patient.Country', 'left')
->join('valueset race', 'race.VID = patient.Race', 'left')
->join('valueset religion', 'religion.VID = patient.Religion', 'left')
->join('valueset ethnic', 'ethnic.VID = patient.Ethnic', 'left')
->join('valueset gender', 'gender.VID = patient.Gender', 'left')
->join('valueset deathindicator', 'deathindicator.VID = patient.DeathIndicator', 'left')
->join('valueset maritalstatus', 'maritalstatus.VID = patient.MaritalStatus', 'left')
```
That's **7 joins to the same table** for ONE patient query. Your database server is crying.
3. **No Easy Identification**
- Primary key is `VID` — an auto-increment integer
- No natural key enforcement
- You want "Male"? Good luck remembering if it's VID 47 or VID 174
4. **Maintenance Nightmare**
- Adding a new Gender option? Better hope you remember the correct `VSetID`
- Want to rename "Other" to "Non-Binary"? Hope you update it in the right row
- Database constraints? What are those?
### What Sane People Do
```sql
-- Option A: Actual ENUMs (MySQL)
ALTER TABLE patient ADD COLUMN gender ENUM('M', 'F', 'O', 'U');
-- Option B: Dedicated lookup tables with meaningful constraints
CREATE TABLE gender (
code VARCHAR(2) PRIMARY KEY,
description VARCHAR(50) NOT NULL
);
ALTER TABLE patient ADD FOREIGN KEY (gender_code) REFERENCES gender(code);
```
---
## 2. 🪆 Organization: The Matryoshka Nightmare
### The Current "Architecture"
```
Account → has Parent (self-referencing)
↳ has Sites
↳ Site → has Parent (self-referencing)
↳ has SiteTypeID → valueset
↳ has SiteClassID → valueset
↳ has Departments
↳ Department → has DisciplineID
↳ Discipline → has Parent (self-referencing)
Plus: Workstations exist somewhere in this chaos
```
### The Philosophical Question
**What IS an organization in this system?**
| Entity | Has Parent? | Has Site? | Has Account? | Is Self-Referencing? |
|--------|-------------|-----------|--------------|----------------------|
| Account | ✅ | ❌ | ❌ | ✅ |
| Site | ✅ | ❌ | ✅ | ✅ |
| Department | ❌ | ✅ | ❌ | ❌ |
| Discipline | ✅ | ✅ | ❌ | ✅ |
So to understand organizational hierarchy, you need to:
1. Traverse Account's parent chain
2. For each Account, get Sites
3. Traverse each Site's parent chain
4. For each Site, get Departments AND Disciplines
5. Traverse each Discipline's parent chain
6. Oh and don't forget Workstations
You basically need a graph database to query what should be a simple org chart.
### What Normal Systems Do
```sql
CREATE TABLE organization (
id INT PRIMARY KEY,
parent_id INT REFERENCES organization(id),
type ENUM('company', 'site', 'department', 'discipline'),
name VARCHAR(255),
code VARCHAR(50)
);
```
**One table. One parent reference. Done.**
---
## 3. 📍 Location + LocationAddress: The Pointless Split
### The Crime
```
location → LocationID, SiteID, LocCode, Parent, LocFull, LocType
locationaddress → LocationID, Street1, Street2, City, Province, PostCode, GeoLocation
```
**LocationAddress uses LocationID as both Primary Key AND Foreign Key.**
This means:
- Every location has **exactly one** address (1:1 relationship)
- You cannot have a location without an address
- You cannot have multiple addresses per location
### Evidence of the Crime
```php
public function saveLocation(array $data): array {
$db->transBegin();
try {
if (!empty($data['LocationID'])) {
$this->update($LocationID, $data);
$modelAddress->update($LocationID, $data); // <-- Always update BOTH
} else {
$LocationID = $this->insert($data, true);
$modelAddress->insert($data); // <-- Always insert BOTH
}
$db->transCommit();
}
}
```
You **always** have to save both tables in a transaction. Because they are fundamentally **ONE entity**.
### The Verdict
If data is always created, updated, and deleted together — **IT BELONGS IN THE SAME TABLE**.
```sql
-- Just combine them:
CREATE TABLE location (
location_id INT PRIMARY KEY,
site_id INT,
loc_code VARCHAR(50),
loc_full VARCHAR(255),
loc_type INT,
street1 VARCHAR(255),
street2 VARCHAR(255),
city INT,
province INT,
post_code VARCHAR(20),
geo_location_system VARCHAR(50),
geo_location_data TEXT
);
```
You just saved yourself a transaction, a join, and 50% of the headache.
---
## 4. 👨‍⚕️ Contact vs Doctor: The Identity Crisis
### The Confusion
```
contact → ContactID, NameFirst, NameLast, Specialty, SubSpecialty, Phone...
contactdetail → ContactDetID, ContactID, SiteID, OccupationID, JobTitle, Department...
occupation → OccupationID, OccupationName...
```
### The Questions Nobody Can Answer
1. **Is a Contact a Doctor?**
- Contact has `Specialty` and `SubSpecialty` fields (doctor-specific)
- But also has generic `OccupationID` via ContactDetail
- So a Contact is Maybe-A-Doctor™?
2. **What prevents non-doctors from being assigned as doctors?**
In `patvisitadt`:
```php
'AttDoc', 'RefDoc', 'AdmDoc', 'CnsDoc' // Attending, Referring, Admitting, Consulting
```
These store ContactIDs. But there's **zero validation** that these contacts are actually doctors. Your receptionist could be the Attending Physician and the database would happily accept it.
3. **Why does ContactDetail exist?**
- It stores the same person's info **per site**
- So one person can have different roles at different sites
- But `Specialty` is on Contact (not ContactDetail), so a doctor has the same specialty everywhere?
- Except `OccupationID` is on ContactDetail, so their occupation changes per site?
### The Solution
```sql
CREATE TABLE person (
person_id INT PRIMARY KEY,
first_name VARCHAR(100),
last_name VARCHAR(100),
-- ... basic personal info
);
CREATE TABLE doctor (
doctor_id INT PRIMARY KEY REFERENCES person(person_id),
specialty VARCHAR(100),
subspecialty VARCHAR(100),
license_number VARCHAR(50)
);
CREATE TABLE site_staff (
person_id INT REFERENCES person(person_id),
site_id INT REFERENCES site(site_id),
role ENUM('doctor', 'nurse', 'technician', 'admin'),
PRIMARY KEY (person_id, site_id)
);
```
Now you can actually **enforce** that only doctors are assigned to doctor fields.
---
## 5. 🏥 Patient Data: The Table Explosion
### The Current State
| Table | Purpose | Why It Exists |
|-------|---------|---------------|
| `patient` | Main patient (30+ columns) | Fair enough |
| `patidt` | Patient Identifiers | One identifier per patient |
| `patcom` | Patient Comments | ONE comment per patient (why a whole table?) |
| `patatt` | Patient "Attachments"... or Addresses? | Stores `Address` as a single string |
### The Crimes
#### Crime 1: Duplicate Address Storage
`patient` table already has:
```
Street_1, Street_2, Street_3, City, Province, ZIP
```
`patatt` table stores:
```
Address (as a single string)
```
**Why do we have both?** Nobody knows. Pick one and commit.
#### Crime 2: One Table for ONE Comment
```php
class PatComModel {
protected $allowedFields = ['InternalPID', 'Comment', 'CreateDate', 'EndDate'];
public function createPatCom(string $patcom, string $newInternalPID) {
$this->insert(["InternalPID" => $newInternalPID, "Comment" => $patcom]);
}
}
```
A whole table. Foreign key constraints. Model class. CRUD operations. **For one text field.**
Just add `comment TEXT` to the patient table.
#### Crime 3: CSV in a Relational Database
```php
$patient['LinkTo'] = '1,5,23,47'; // Comma-separated patient IDs
```
```php
private function getLinkedPatients(?string $linkTo): ?array {
$ids = array_filter(explode(',', $linkTo)); // Oh no
return $this->db->table('patient')->whereIn('InternalPID', $ids)->get();
}
```
**It's 2025.** Use a junction table:
```sql
CREATE TABLE patient_link (
patient_id INT,
linked_patient_id INT,
link_type VARCHAR(50),
PRIMARY KEY (patient_id, linked_patient_id)
);
```
---
## 6. 🤮 Patient Admission (ADT): Event Sourcing Gone Wrong
### The Pattern
```
patvisit → InternalPVID, PVID, InternalPID (the visit)
patvisitadt → PVADTID, InternalPVID, ADTCode, LocationID, AttDoc... (ADT events)
patdiag → InternalPVID, DiagCode, Diagnosis
```
Instead of tracking patient status with simple fields, every Admission/Discharge/Transfer creates a **new row**.
### The Query From Hell
To get the current status of a patient visit:
```php
->join('(SELECT a1.*
FROM patvisitadt a1
INNER JOIN (
SELECT InternalPVID, MAX(PVADTID) AS MaxID
FROM patvisitadt
GROUP BY InternalPVID
) a2 ON a1.InternalPVID = a2.InternalPVID AND a1.PVADTID = a2.MaxID
) AS patvisitadt',
'patvisitadt.InternalPVID = patvisit.InternalPVID',
'left')
```
Every. Single. Query. To get current patient status.
### The Performance Analysis
| Rows in patvisitadt | Query Complexity |
|---------------------|------------------|
| 1,000 | Meh, fine |
| 10,000 | Getting slow |
| 100,000 | Coffee break |
| 1,000,000 | Go home |
### What You Should Do
**Option A: Just use status fields**
```sql
ALTER TABLE patvisit ADD COLUMN current_status ENUM('admitted', 'discharged', 'transferred');
ALTER TABLE patvisit ADD COLUMN current_location_id INT;
ALTER TABLE patvisit ADD COLUMN current_attending_doctor_id INT;
```
**Option B: If you NEED history, use proper triggers**
```sql
CREATE TRIGGER update_current_status
AFTER INSERT ON patvisitadt
FOR EACH ROW
UPDATE patvisit SET current_status = NEW.adt_code WHERE InternalPVID = NEW.InternalPVID;
```
---
## 7. 🧪 Test Definitions: The Abbreviation Cemetery
### The Tables
| Table | What Is This? | Fields |
|-------|---------------|--------|
| `testdefsite` | Test per Site | TestSiteID, TestSiteCode, TestSiteName, SeqScr, SeqRpt, VisibleScr, VisibleRpt... |
| `testdefgrp` | Test Groups | TestGrpID, TestSiteID, Member |
| `testdefcal` | Calculated Tests | TestCalID, TestSiteID, DisciplineID, DepartmentID... |
| `testdeftech` | Technical Details | TestTechID, TestSiteID, DisciplineID, DepartmentID... |
| `testmap` | ??? | TestMapID, TestSiteID... |
| `refnum` | Numeric Reference Ranges | RefNumID, TestSiteID, Sex, AgeStart, AgeEnd, Low, High... |
| `reftxt` | Text Reference Ranges | RefTxtID, TestSiteID, Sex, AgeStart, AgeEnd, RefTxt... |
| `refvset` | ValueSet Reference | RefVSetID, TestSiteID... |
| `refthold` | Thresholds | RefTHoldID... |
### The Abbreviation Apocalypse
| Abbreviation | Meaning | Guessability |
|--------------|---------|--------------|
| `SeqScr` | Sequence Screen | 2/10 |
| `SeqRpt` | Sequence Report | 3/10 |
| `SpcType` | Specimen Type | 4/10 |
| `VID` | ValueSet ID | 1/10 |
| `InternalPID` | Internal Patient ID | 5/10 |
| `InternalPVID` | Internal Patient Visit ID | 4/10 |
| `PVADTID` | Patient Visit ADT ID | 0/10 |
| `TestDefCal` | Test Definition Calculation | 3/10 |
| `RefTHold` | Reference Threshold | 1/10 |
**Pro tip:** If new developers need a glossary to understand your schema, you've failed.
### The Split Between RefNum and RefTxt
For numeric tests: use `refnum`
For text tests: use `reftxt`
Why not:
```sql
CREATE TABLE reference_range (
id INT PRIMARY KEY,
test_id INT,
range_type ENUM('numeric', 'text'),
low_value DECIMAL,
high_value DECIMAL,
text_value VARCHAR(255),
-- ... other fields
);
```
One table. One query. One life.
---
## 8. 🎭 Bonus Round: Sins I Couldn't Ignore
### Sin #1: Inconsistent Soft Delete Field Names
| Table | Delete Field | Why Different? |
|-------|--------------|----------------|
| Most tables | `EndDate` | ??? |
| patient | `DelDate` | ??? |
| patatt | `DelDate` | ??? |
| patvisitadt | `EndDate` AND `ArchivedDate` AND `DelDate` | ¯\\\_(ツ)\_/¯ |
### Sin #2: Primary Key With a Trailing Space
In `PatComModel.php`:
```php
protected $primaryKey = 'PatComID '; // <-- THERE IS A SPACE HERE
```
This has either:
- Never been tested
- Works by pure accident
- Will explode randomly one day
### Sin #3: Inconsistent ID Naming
| Column | Location |
|--------|----------|
| `InternalPID` | patient, patatt, patcom, patidt, patvisit... |
| `PatientID` | Also on patient table |
| `ContactID` | contact |
| `ContactDetID` | contactdetail |
| `VID` | valueset |
| `VSetID` | Also valueset and valuesetdef |
| `TestSiteID` | testdefsite |
| `TestGrpID` | testdefgrp |
Pick a convention. ANY convention. Please.
### Sin #4: Multiple Date Tracking Fields with Unclear Purposes
On `testdefsite`:
- `CreateDate` — when created
- `StartDate` — when... started? Different from created how?
- `EndDate` — when ended (soft delete)
### Sin #5: No Data Validation
The `patient` model has 30+ fields including:
- `Gender` (valueset VID — could be literally anything)
- `Religion` (valueset VID — could be a LocationType)
- `DeathIndicator` (valueset VID — could be a Gender)
Zero database-level constraints. Zero model-level validation. Pure vibes.
---
## 🏆 The Final Scorecard
| Category | Rating | Notes |
|----------|--------|-------|
| **Normalization** | 2/10 | Either over-normalized (LocationAddress) or under-normalized (CSV in columns) |
| **Consistency** | 1/10 | Every table is a unique snowflake |
| **Performance** | 3/10 | Those MAX subqueries and 7-way joins will age poorly |
| **Maintainability** | 1/10 | Good luck onboarding new developers |
| **Type Safety** | 0/10 | ValueSet is a type-safety black hole |
| **Naming** | 2/10 | Abbreviation chaos |
| **Scalability** | 2/10 | Event-sourcing-but-wrong will not scale |
**Overall: 1.5/10** — *"At least the tables exist"*
---
## 💡 Recommendations
If you want to fix this (and you should), here's the priority order:
1. **Eliminate the ValueSet monster** — Replace with proper ENUMs or dedicated lookup tables with foreign key constraints
2. **Combine 1:1 tables** — Location + LocationAddress, Patient + PatCom (if it's really just one comment)
3. **Fix Patient data model** — Proper junction tables for PatIdt, PatAtt, and LinkTo
4. **Add current status to PatVisit** — Denormalize the ADT current state
5. **Standardize naming** — Pick `*_id`, `*ID`, or `Id` and stick with it. Pick `end_date` or `del_date` for soft deletes.
6. **Add actual constraints** — Foreign keys that make sense. Check constraints. Not just vibes.
---
## 📜 Conclusion
This schema is what happens when someone:
- Prioritizes "flexibility" over usability
- Learns about normalization but not when to stop
- Discovers self-referencing tables and uses them everywhere
- Thinks abbreviations save storage space (they don't)
- Has never had to maintain their own code
The good news: it can be fixed.
The bad news: it should have been designed correctly the first time.
---
*Document prepared with 🔥 and ☕*
*May your database queries be fast and your schemas be sane.*

170
src/blog/clqms-roast-zai.md Normal file
View File

@ -0,0 +1,170 @@
---
title: "Database Design Roast"
description: "A legendary roast of the CLQMS database schema, highlighting the architectural hazards and data hell."
date: 2026-01-07
order: 7
tags:
- posts
- clqms
layout: clqms-post.njk
---
# The Database Design from Hell: A Comprehensive Roast
## *Or: Why your current project makes you want to quit.*
You are right to be sick of this. This document is a masterclass in how **not** to design a database. It takes simple concepts and wraps them in layers of unnecessary, redundant, and contradictory complexity.
Here is the systematic destruction of every data relation and design choice that is causing you pain.
---
## 1. The "Value Set" Disaster (The God Table)
**The Design:**
Storing every single enumeration (Dropdown list, Flag, Reference Text, Status) in one giant generic table (`ValueSet` / `codedtxtfld`).
**The Roast:**
* **No Identity:** You mentioned "don't have an easy identifier." This is because they likely didn't use a Surrogate Key (an auto-incrementing ID). They probably used the `Code` (e.g., "M" for Male) as the Primary Key.
* *Why it sucks:* If you ever need to change "M" to "Male", you break every single Foreign Key in the database that points to it. You can't update a key that is being used elsewhere. Your data is frozen in time forever.
* **Performance Killer:** Imagine loading a dropdown for "Gender." The database has to scan a table containing 10,000 rows (all flags, all statuses, all colors, all text results) just to find "M" and "F".
* **Zero Integrity:** Because it's a generic table, you can't enforce specific rules. You can accidentally delete a value used for "Critical Patient Status" because the table thinks it's just a generic string. There is no referential integrity. It's the Wild West.
---
## 2. The "Organization" Nightmare
**The Design:**
Attempting to model a global conglomerate structure of Accounts, Sites, Disciplines, Departments, Workstations, and Parent-Child hierarchies.
**The Roast:**
* **Over-Engineering:** You are building a database for a Laboratory, not the United Nations. Why does a lab system need a recursive `Account` structure that handles "Parent/Child" relationships for companies?
* **The Blob:** `Account` comes from CRM, but `Site` comes from CRM, yet `Discipline` is internal. You are mixing external business logic (Sales) with internal operational logic (Lab Science).
* **Identity Confusion:** Is a "Department" a physical place? A group of people? Or a billing category? In this design, it's all three simultaneously. This makes generating a simple report like "Who works in Hematology?" a complex query involving `Organization`, `Personnel`, and `Location`.
---
## 3. The "Location" & "LocationAddress" Split
**The Design:**
Separating the Location definition (Name, Type) from its Address (Street, City) into two different tables linked 1-to-1.
**The Roast:**
* **The "Why?" Factor:** Why? Is a Bed (Location) going to have multiple addresses? No. Is a Building going to have multiple addresses? No.
* **Performance Tax:** Every single time you need to print a label or show where a sample is, you **must** perform a `JOIN`.
* *Bad Design:* `SELECT * FROM Location l JOIN LocationAddress a ON l.id = a.id`
* **The Null Nightmare:** For mobile locations (Home Care), the address is vital. For static locations (Bed 1), the address is meaningless (it's just coordinates relative to the room). By forcing a split, you either have empty rows in `LocationAddress` or you have to invent fake addresses for beds. It's pointless normalization.
---
## 4. The "HostApp" Table
**The Design:**
A specific table dedicated to defining external applications (`HostApp`) that integrate with the system.
**The Roast:**
* **The Myth of Modularity:** This table pretends the system is "plug-and-play" with other apps. But look at the Appendices: "Calibration Results SQL Scripts." That's hard-coded SQL, not a dynamic plugin.
* **Maintenance Hell:** This table implies you need to map every single field from every external app.
* *Scenario:* Hospital A uses HIS "MediTech". Hospital B uses HIS "CarePoint".
* *Result:* You need a `HostApp` row for MediTech and one for CarePoint. Then you need mapping tables for Patient, Order, Result, etc. You are building an ETL (Extract, Transform, Load) tool inside a Lab database. It's out of scope.
---
## 5. The "Doctor" vs "Contact" Loop
**The Design:**
A `Contact` table that stores generic people, and a `Doctor` table that... also stores people? Or does it reference Contact?
**The Roast:**
* **The Infinite Join:** To get a Doctor's name, do you query `Doctor` or `Contact`?
* If `Doctor` extends `Contact` (1-to-1), you have to join every time.
* If `Doctor` is just a row in `Contact` with a `Type='Doctor'`, why does the `Doctor` table exist?
* **Semantic Mess:** A "Contact" usually implies "How to reach." A "Doctor" implies "Medical License."
* **The Failure:** If Dr. Smith retires, do you delete the `Doctor` record? Yes. But then you delete his `Contact` info, so you lose his phone number for historical records. This design doesn't separate the *Role* (Doctor) from the *Entity* (Person). It's a data integrity nightmare.
---
## 6. Patient Data: The Actual Hell
**The Design:**
Storing Patients, Non-Patients (Blood bags), External QC, and linking/unlinking them all in one messy structure.
**The Roast:**
* **Blood Bags are not Humans:**
* The document explicitly says "mengelola non-patient entity... blood bag."
* *Result:* Your `Patient` table has `DateOfBirth` (Required) and `BloodType` (Required). For a Blood Bag, DOB is NULL. For a Human, BloodType might be NULL.
* You have created a "Sparse Table" (50% NULLs). It's impossible to index effectively. It breaks the very definition of what a "Patient" is.
* **The "Link/Unlink" Suicide Pact:**
* "Menghubungkan (link)/mengurai(unlink) data pasien."
* *Audit Trail Death:* If I link "John Doe" from Site A to "John Doe" from Site B, and then later "Unlink" them, the database loses the history of that decision. Why did we unlink them? Was it a mistake? The design doesn't track the *decision*, it just changes the data.
* **Confusion:** `Patient Registration` vs `Patient Admission`. Why are these two different giant workflows? In every other system on earth, you Register (create ID) and Admit (start visit). This document treats them like they require NASA-level calculations.
---
## 7. Patient Admission: Revenue Cycle Virulence
**The Design:**
Admission is tightly coupled with "Pihak yang menanggung biaya" (Payer) and "Tarif" (Price).
**The Roast:**
* **It's a Billing System, not a Lab System:** This section reads like an Accounting module. The Lab database should not care if the patient pays with BlueCross or Cash.
* **The Logic Trap:** If the Admission fails because the "Tarif" (Price) is missing, does the Lab stop processing the blood sample?
* *According to this design:* Probably yes.
* *In reality:* The patient could be dying. Clinical safety should never be blocked by administrative billing data. Mixing these concerns is dangerous.
---
## 8. Test Data: The Maze of Redundancy
**The Design:**
`testdef`, `testdefsite`, `testdeftech`, `testgrp`, `refnum`, `reftxt`, `fixpanel`.
**The Roast:**
* **Definition Explosion:**
* `testdefsite`: Defines "Glucose" for Site A.
* `testdeftech`: Defines "Glucose" for Machine B.
* *Reality:* Glucose is Glucose. The chemical reaction doesn't change because the building is different.
* *Cost:* You have to update the Reference Range for Glucose in 50 different places if the lab director decides to change it.
* **The "Group" Soup:**
* `Profile` (One tube), `Functional Procedure` (Time series), `Superset` (Billing).
* These are stored in `testgrp` as if they are the same thing.
* *Failure:** You can't enforce logic. The system allows you to add a "2-Hour Post-Prandial" (Time-based) to a "Lipid Panel" (One-tube) because to the database, they are just "Tests in a Group."
* **`refnum` vs `reftxt`:**
* Why split them? A Reference Range is data. Whether it's "10-20" (Numeric) or "Positive/Negative" (Text) is just a formatting rule. Splitting them into two tables doubles your JOINs and complicates the query logic for no reason.
---
## 9. BONUS: Other Disasters I Found
### A. Equipment & The "Mousepad" Tracking
**The Roast:**
The document defines Equipment as: "termasuk UPS, AVR, printer, PC... mouse, keyboard."
* **The Trap:** Do you really need a depreciation schedule and maintenance log for a mouse? By lumping "IVD Analyzers" (Critical Medical Devices) with "Computer Mice" (Office Supplies), you clutter the Equipment table with garbage data.
* **Fix:** Separate `CapitalAsset` (Machines) from `InventoryItem` (Supplies).
### B. Specimen: The "Parent" Trap
**The Roast:**
Secondary Specimen has a `ParentID` pointing to Primary Specimen.
* **The Void:** There is no tracking of volume. If Tube A (Parent) has 5ml, and you create Tube B (Child/Aliquot) with 1ml, the database does not know that Tube A now only has 4ml left.
* **The Consequence:** You cannot do automated inventory. You can't alert the user "Running low on sample!" because the database thinks the sample is infinite.
### C. The "Red Font" Encryption
**The Roast:**
"Encrypted = Red Font."
* **The Joke:** This is technically illiterate. A database stores bytes. It does not store "Red Font."
* **The Risk:** If they literally store `<b><span color='red'>PatientName</span></b>` in the database, you have corrupted your data.
* **The Reality:** If it's just a UI setting, why is it in the database requirements section? It proves the writer doesn't know the difference between Data Storage and Data Presentation.
---
## Summary: Why you want to vomit
This document is a "Jack of All Trades, Master of None."
It tries to be:
1. **CRM** (Contact/Account mgmt)
2. **ERP** (Inventory/Asset mgmt)
3. **Billing System** (Admission/Tariff)
4. **Laboratory Information System** (The actual work)
By trying to do all of this in a single, poorly normalized schema, it creates a **Data Hell** where nothing is reliable, nothing is fast, and changing one thing breaks three others.
**Do not build this "as is."** If you do, you will spend the next 5 years writing `SQL Scripts` to patch the holes in this sinking ship.

View File

@ -2,7 +2,7 @@
title: "Project Pandaria: Next-Gen LIS Architecture" title: "Project Pandaria: Next-Gen LIS Architecture"
description: "An offline-first, event-driven architecture concept for the CLQMS." description: "An offline-first, event-driven architecture concept for the CLQMS."
date: 2025-12-19 date: 2025-12-19
order: 6 order: 8
tags: tags:
- posts - posts
- clqms - clqms

View File

@ -2,7 +2,7 @@
title: "Edge Workstation: SQLite Database Schema" title: "Edge Workstation: SQLite Database Schema"
description: "Database design for the offline-first smart workstation." description: "Database design for the offline-first smart workstation."
date: 2025-12-19 date: 2025-12-19
order: 7 order: 9
tags: tags:
- posts - posts
- clqms - clqms

View File

@ -16,7 +16,7 @@ description: Our projects and technical showcase
</p> </p>
</div> </div>
<!-- Proposals List --> <!-- Proposals List -->
<div class="max-w-3xl mx-auto space-y-6"> <div class="space-y-6">
{% for post in collections.posts %} {% for post in collections.posts %}
<a href="{{ post.url }}" class="post-card block group"> <a href="{{ post.url }}" class="post-card block group">
<div class="flex flex-col md:flex-row md:items-start gap-4"> <div class="flex flex-col md:flex-row md:items-start gap-4">

View File

@ -1,5 +1,4 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "daisyui";
/* ======================================== /* ========================================
THEME CONFIGURATION THEME CONFIGURATION
@ -62,78 +61,53 @@
} }
/* ======================================== /* ========================================
DAISYUI THEME CUSTOMIZATION CSS CUSTOM PROPERTIES - THEME COLORS
======================================== */ ======================================== */
@plugin "daisyui" { :root {
themes: panda --default, pandaLight; /* Default: Dark theme with blue accents */
} --color-base-100: oklch(0.15 0.02 250);
--color-base-200: oklch(0.18 0.025 250);
--color-base-300: oklch(0.25 0.03 250);
--color-base-content: oklch(0.95 0.01 250);
@plugin "daisyui/theme" { --color-primary: oklch(0.70 0.20 240);
name: "panda"; --color-primary-content: oklch(1 0 0);
default: true; --color-secondary: oklch(0.75 0.18 200);
/* Fun dark theme - cozy night with pops of color */ --color-secondary-content: oklch(0.15 0.02 200);
--color-base-100: oklch(0.18 0.015 280); --color-accent: oklch(0.65 0.25 260);
/* Deep purple-ish dark */ --color-accent-content: oklch(1 0 0);
--color-base-200: oklch(0.22 0.02 280); --color-neutral: oklch(0.28 0.02 250);
/* Slightly lighter */ --color-neutral-content: oklch(0.92 0.01 250);
--color-base-300: oklch(0.30 0.025 280);
/* Cards and borders */
--color-base-content: oklch(0.95 0.01 280);
/* Crisp white text */
--color-primary: oklch(0.75 0.18 15);
/* 🧡 Coral/Peach - warm and fun */
--color-primary-content: oklch(0.15 0.02 15);
--color-secondary: oklch(0.70 0.20 300);
/* 💜 Electric Purple - playful */
--color-secondary-content: oklch(1 0 0);
--color-accent: oklch(0.78 0.15 175);
/* 🌊 Minty Teal - fresh pop */
--color-accent-content: oklch(0.15 0.02 175);
--color-neutral: oklch(0.28 0.02 280);
--color-neutral-content: oklch(0.92 0.01 280);
--color-info: oklch(0.72 0.14 230); --color-info: oklch(0.72 0.14 230);
/* Sky blue */
--color-success: oklch(0.75 0.18 155); --color-success: oklch(0.75 0.18 155);
/* Lime green */
--color-warning: oklch(0.82 0.16 85); --color-warning: oklch(0.82 0.16 85);
/* Sunny yellow */
--color-error: oklch(0.68 0.20 25); --color-error: oklch(0.68 0.20 25);
/* Soft red */
--radius-box: 1.25rem; --radius-box: 1.25rem;
--radius-btn: 0.75rem; --radius-btn: 0.75rem;
--radius-badge: 9999px; --radius-badge: 9999px;
} }
@plugin "daisyui/theme" { /* Light theme */
name: "pandaLight"; [data-theme="light"] {
/* Fun light theme - bright and cheerful */ --color-base-100: oklch(0.99 0.005 240);
--color-base-100: oklch(0.99 0.005 280); --color-base-200: oklch(0.96 0.01 240);
/* Off-white */ --color-base-300: oklch(0.92 0.015 240);
--color-base-200: oklch(0.96 0.01 280); --color-base-content: oklch(0.25 0.03 240);
/* Light lavender tint */
--color-base-300: oklch(0.92 0.015 280); --color-primary: oklch(0.55 0.25 240);
/* Subtle purple-gray */
--color-base-content: oklch(0.25 0.03 280);
/* Dark purple text */
--color-primary: oklch(0.65 0.20 15);
/* 🧡 Coral - punchy */
--color-primary-content: oklch(1 0 0); --color-primary-content: oklch(1 0 0);
--color-secondary: oklch(0.58 0.22 300); --color-secondary: oklch(0.60 0.22 200);
/* 💜 Rich Purple */
--color-secondary-content: oklch(1 0 0); --color-secondary-content: oklch(1 0 0);
--color-accent: oklch(0.65 0.18 175); --color-accent: oklch(0.50 0.28 260);
/* 🌊 Teal */
--color-accent-content: oklch(1 0 0); --color-accent-content: oklch(1 0 0);
--color-neutral: oklch(0.45 0.03 280); --color-neutral: oklch(0.45 0.03 240);
--color-neutral-content: oklch(0.98 0 0); --color-neutral-content: oklch(0.98 0 0);
--color-info: oklch(0.62 0.16 230); --color-info: oklch(0.62 0.16 230);
--color-success: oklch(0.65 0.20 155); --color-success: oklch(0.65 0.20 155);
--color-warning: oklch(0.78 0.18 85); --color-warning: oklch(0.78 0.18 85);
--color-error: oklch(0.58 0.22 25); --color-error: oklch(0.58 0.22 25);
--radius-box: 1.25rem;
--radius-btn: 0.75rem;
--radius-badge: 9999px;
} }
/* ======================================== /* ========================================
@ -149,6 +123,9 @@
font-family: var(--font-sans); font-family: var(--font-sans);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
background-color: var(--color-base-100);
color: var(--color-base-content);
transition: background-color 0.3s ease, color 0.3s ease;
} }
::selection { ::selection {
@ -157,11 +134,379 @@
} }
/* ======================================== /* ========================================
COMPONENT STYLES COMPONENT STYLES - BUTTONS
======================================== */ ======================================== */
@layer components { @layer components {
/* Button base */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
font-weight: 500;
border-radius: var(--radius-btn);
transition: all 0.2s ease;
cursor: pointer;
border: 1px solid transparent;
text-decoration: none;
}
.btn:hover {
transform: translateY(-1px);
}
.btn-primary {
background-color: var(--color-primary);
color: var(--color-primary-content);
}
.btn-primary:hover {
filter: brightness(1.1);
}
.btn-outline {
background: transparent;
border-color: currentColor;
}
.btn-outline.btn-primary {
color: var(--color-primary);
background: transparent;
}
.btn-outline.btn-primary:hover {
background-color: var(--color-primary);
color: var(--color-primary-content);
}
.btn-ghost {
background: transparent;
border: none;
}
.btn-ghost:hover {
background-color: var(--color-base-300);
}
.btn-circle {
border-radius: 9999px;
padding: 0.5rem;
width: 2.5rem;
height: 2.5rem;
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
}
.btn-sm.btn-circle {
width: 2rem;
height: 2rem;
padding: 0.25rem;
}
.btn-lg {
padding: 1rem 2rem;
font-size: 1.125rem;
}
/* ========================================
BADGES
======================================== */
.badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: var(--radius-badge);
border: 1px solid transparent;
}
.badge-primary {
background-color: var(--color-primary);
color: var(--color-primary-content);
}
.badge-secondary {
background-color: var(--color-secondary);
color: var(--color-secondary-content);
}
.badge-accent {
background-color: var(--color-accent);
color: var(--color-accent-content);
}
.badge-warning {
background-color: var(--color-warning);
color: oklch(0.2 0 0);
}
.badge-success {
background-color: var(--color-success);
color: oklch(0.2 0 0);
}
.badge-error {
background-color: var(--color-error);
color: oklch(1 0 0);
}
.badge-ghost {
background-color: var(--color-base-300);
color: var(--color-base-content);
}
.badge-outline {
background: transparent;
border-color: currentColor;
}
.badge-outline.badge-primary {
color: var(--color-primary);
background: transparent;
}
.badge-outline.badge-secondary {
color: var(--color-secondary);
}
.badge-outline.badge-accent {
color: var(--color-accent);
}
.badge-outline.badge-warning {
color: var(--color-warning);
}
.badge-outline.badge-success {
color: var(--color-success);
}
.badge-outline.badge-error {
color: var(--color-error);
}
.badge-lg {
padding: 0.375rem 1rem;
font-size: 0.875rem;
}
.badge-sm {
padding: 0.125rem 0.5rem;
font-size: 0.625rem;
}
/* ========================================
NAVBAR
======================================== */
.navbar {
display: flex;
align-items: center;
padding: 0.5rem 1rem;
width: 100%;
}
.navbar-start {
display: flex;
align-items: center;
flex: 1;
}
.navbar-center {
display: flex;
align-items: center;
justify-content: center;
}
.navbar-end {
display: flex;
align-items: center;
justify-content: flex-end;
flex: 1;
gap: 0.25rem;
}
/* ========================================
MENU
======================================== */
.menu {
display: flex;
flex-direction: column;
list-style: none;
padding: 0;
margin: 0;
}
.menu-horizontal {
flex-direction: row;
}
.menu-sm li>a,
.menu-sm li>button,
.menu-sm li details summary {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
}
.menu li {
position: relative;
display: flex;
align-items: center;
}
.menu li>a,
.menu li>button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
text-decoration: none;
color: var(--color-base-content);
transition: all 0.2s ease;
}
.menu li details {
position: relative;
display: flex;
align-items: center;
}
.menu li details summary {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
list-style: none;
color: var(--color-base-content);
font-size: 0.875rem;
line-height: 1.25rem;
height: auto;
margin: 0;
}
.menu li details summary::-webkit-details-marker {
display: none;
}
.menu li details summary::after {
content: "";
width: 0.4rem;
height: 0.4rem;
border-right: 2px solid currentColor;
border-bottom: 2px solid currentColor;
transform: rotate(45deg);
margin-left: auto;
transition: transform 0.2s ease;
}
.menu li details[open] summary::after {
transform: rotate(-135deg);
}
.menu li details>ul {
position: absolute;
top: 100%;
left: 0;
min-width: 200px;
flex-direction: column;
margin-top: 0.25rem;
}
/* ========================================
CARD
======================================== */
.card {
display: flex;
flex-direction: column;
border-radius: var(--radius-box);
overflow: hidden;
}
.card-body {
display: flex;
flex-direction: column;
padding: 1.5rem;
gap: 0.5rem;
}
.card-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.25rem;
font-weight: 600;
}
/* ========================================
FOOTER
======================================== */
.footer {
display: flex;
flex-wrap: wrap;
gap: 2rem;
padding: 2rem 1rem;
}
.footer-center {
justify-content: center;
align-items: center;
flex-direction: column;
text-align: center;
}
/* ========================================
THEME TOGGLE
======================================== */
.theme-toggle {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
}
.theme-toggle .icon-sun,
.theme-toggle .icon-moon {
position: absolute;
transition: opacity 0.3s ease, transform 0.3s ease;
}
.theme-toggle .icon-sun {
opacity: 1;
transform: rotate(0deg);
}
.theme-toggle .icon-moon {
opacity: 0;
transform: rotate(-90deg);
}
[data-theme="light"] .theme-toggle .icon-sun {
opacity: 0;
transform: rotate(90deg);
}
[data-theme="light"] .theme-toggle .icon-moon {
opacity: 1;
transform: rotate(0deg);
}
/* ========================================
ORIGINAL CUSTOM COMPONENTS
======================================== */
/* Gradient text effect */ /* Gradient text effect */
.gradient-text { .gradient-text {
background: linear-gradient(to right, var(--color-primary), var(--color-secondary), var(--color-accent)); background: linear-gradient(to right, var(--color-primary), var(--color-secondary), var(--color-accent));
@ -408,6 +753,138 @@
border-top: 1px solid oklch(1 0 0 / 0.1); border-top: 1px solid oklch(1 0 0 / 0.1);
margin: 2em 0; margin: 2em 0;
} }
/* ========================================
TAILWIND COLOR UTILITIES
======================================== */
.bg-base-100 {
background-color: var(--color-base-100);
}
.bg-base-200 {
background-color: var(--color-base-200);
}
.bg-base-300 {
background-color: var(--color-base-300);
}
.text-base-content {
color: var(--color-base-content);
}
.text-primary {
color: var(--color-primary);
}
.text-secondary {
color: var(--color-secondary);
}
.text-accent {
color: var(--color-accent);
}
.text-warning {
color: var(--color-warning);
}
.text-success {
color: var(--color-success);
}
.text-error {
color: var(--color-error);
}
.border-base-200 {
border-color: var(--color-base-200);
}
.border-base-300 {
border-color: var(--color-base-300);
}
.border-primary {
border-color: var(--color-primary);
}
.hover\:bg-base-100:hover {
background-color: var(--color-base-100);
}
.hover\:bg-base-300:hover {
background-color: var(--color-base-300);
}
.hover\:text-base-content:hover {
color: var(--color-base-content);
}
.hover\:text-primary:hover {
color: var(--color-primary);
}
.hover\:text-secondary:hover {
color: var(--color-secondary);
}
.hover\:text-warning:hover {
color: var(--color-warning);
}
.hover\:text-success:hover {
color: var(--color-success);
}
.hover\:text-error:hover {
color: var(--color-error);
}
.hover\:border-primary\/50:hover {
border-color: oklch(0.75 0.18 15 / 0.5);
}
.hover\:border-secondary\/50:hover {
border-color: oklch(0.70 0.20 300 / 0.5);
}
.hover\:border-warning\/50:hover {
border-color: oklch(0.82 0.16 85 / 0.5);
}
.hover\:border-success\/50:hover {
border-color: oklch(0.75 0.18 155 / 0.5);
}
.hover\:border-error\/50:hover {
border-color: oklch(0.68 0.20 25 / 0.5);
}
.group:hover .group-hover\:text-primary {
color: var(--color-primary);
}
.group:hover .group-hover\:text-secondary {
color: var(--color-secondary);
}
.group:hover .group-hover\:text-warning {
color: var(--color-warning);
}
.group:hover .group-hover\:text-success {
color: var(--color-success);
}
.group:hover .group-hover\:text-error {
color: var(--color-error);
}
.group:hover .group-hover\:bg-primary\/20 {
background-color: oklch(0.75 0.18 15 / 0.2);
}
} }
/* ======================================== /* ========================================