🔁 ทวนก่อนเริ่ม — Lab นี้ใช้คำพวกนี้ตลอด ถ้าข้อไหนไม่คุ้นให้กลับไปบทนั้นก่อน
- layout = จานรองมาตรฐานที่ห่อทุกหน้า ·
<%= yield %>= ช่องกลางจานที่ใส่อาหารของแต่ละเมนู (บท 123) - partial = แม่พิมพ์ใช้ซ้ำ ไฟล์ขึ้นต้นด้วย
_· บรรทัดแรก<%# locals: (…) -%>= ป้ายบอกว่าแม่พิมพ์รับของอะไรบ้าง (บท 123) - daisyUI = ชุดจานชามสำเร็จรูปบน Tailwind: เขียน
btn btn-primaryแทนคลาส Tailwind ยาวเหยียด · semantic color = เรียกสีตาม "หน้าที่" (primary·base-100) ไม่ใช่ตามชื่อสี (บท 128) bin/dev= เปิดร้าน + คนคอยสร้าง CSS ใหม่ทุกครั้งที่เราแก้ view (บท 128) ·assets:precompile= เตรียมไฟล์ CSS/JS แบบที่จะขึ้นเครื่องจริง ติดเลขรุ่นท้ายชื่อไฟล์ (บท 127)useEffectของ React = โค้ดที่รันเองหลัง component ขึ้นจอ (บท 74) — วันนี้จะเจอญาติของมันฝั่ง Rails
🧰 ตรวจด้วย Ruby 4.0.9 · Rails 8.1.4 · tailwindcss-rails 4.7.0 (Tailwind 4.3.3) · daisyUI 5.7.50 · stimulus-rails 1.3.4 (สไลด์และ repo ของอาจารย์ใช้ Ruby 4.0.5–4.0.6 · Rails 8.1.3 — รุ่นแก้บั๊กในสายเดียวกัน เรื่องในบทนี้ไม่ต่างกัน)
🗂 โค้ดเริ่มจาก: แอปหลังจบ Lab บท 126 ที่ commit ไว้ใน branch my-day3 (ตามไม่ทันดูบท 126) ·
บทนี้เก็บจริง — จบแล้ว commit · บท 130 เริ่มจากตรงนี้ ·
branch day3-b-done ของอาจารย์ทำเรื่องเดียวกัน แต่หน้าตาไฟล์ต่างจากบทนี้ (ใช้ระบบแปลภาษา t(".title") และไอคอนอีกแบบ) อย่าก็อปปนกัน
ผลทุกก้อนรันจริงแล้วก็อปมา · ตัดบรรทัดที่ไม่เกี่ยวเป็น … · ค่าที่เปลี่ยนทุกครั้ง เช่น เวลา ms · เลข commit · คำว่า "1 day ago" (นับจากวันที่คุณรัน seeds) ของคุณจะต่าง ·
สีที่วัดได้เขียนเป็น oklch(…) — คือวิธีเขียนสีแบบหนึ่งของ CSS ไม่ต้องอ่านตัวเลขเป็น แค่ดูว่า "เปลี่ยน" หรือ "ไม่เปลี่ยน"
แผนที่ของ Lab: ทำเองก่อน แล้วค่อยเขียนใบสั่งให้ AI
สไลด์หน้า 34 ให้ "ใบสั่งงาน AI" มาหนึ่งใบ (เป้าหมาย · ข้อกำหนด · ห้าม · เกณฑ์ตรวจรับ — สี่ส่วนเดียวกับบท 121) · บทนี้จะทำทุกข้อในใบนั้นด้วยมือก่อน เพื่อให้รู้ว่างานที่ถูกหน้าตาเป็นยังไง · พอถึงตอนท้าย คุณจะอ่านงานของ AI ออกว่าขาดอะไร
ไฟล์ที่แตะทั้งหมด 19 ไฟล์ ฟังดูเยอะ แต่เกือบทั้งหมดคือเปลี่ยนชื่อคลาส — logic ของ Ruby แทบไม่ขยับเลย · ของใหม่จริง ๆ มีแค่ขั้น 1–3 · ขั้น 4–5 คือฝึกมือ
ขั้น 0 — เตรียมร้านก่อนเริ่ม
เปิด terminal สองหน้าต่าง (หรือสองแท็บ) ที่โฟลเดอร์ของแอป · หน้าต่างแรกปล่อยร้านเปิดค้างไว้ หน้าต่างที่สองไว้พิมพ์คำสั่งอื่น:
# หน้าต่างที่ 1 — เปิดร้านพร้อมคนคอยสร้าง CSS (ต้องเป็น bin/dev ไม่ใช่ bin/rails server)
$ bin/dev
# หน้าต่างที่ 2 — ต้องไม่ขึ้นอะไรเลย = ไม่มีของทดลองค้างจากบท 127–128
$ git status --short
⚠️ ต้องเป็น bin/dev · ถ้าเปิดแค่ bin/rails server คลาสใหม่ที่คุณพิมพ์ (เช่น card) จะไม่มีในไฟล์ CSS เพราะไม่มีใครสร้างให้ใหม่ → หน้าเว็บไม่เปลี่ยน แล้วจะนึกว่าตัวเองพิมพ์ผิด (บท 128)
· ถ้า git status --short ขึ้นชื่อไฟล์ ให้ git restore . ก่อน (= ทิ้งทุกการแก้ที่ยังไม่ได้ commit กลับไปเป็นแบบ commit ล่าสุด · บรรทัดที่ขึ้นต้นด้วย ?? คือไฟล์ใหม่ที่ git ไม่รู้จัก คำสั่งนี้ไม่ลบให้ ต้องลบไฟล์นั้นเอง)
ขั้น 1 — เปิดธีมใน CSS: บอก daisyUI ว่าร้านเรามีธีมอะไรบ้าง
ธีม (theme) คือชุดสีทั้งชุดของเว็บ: สีพื้น สีตัวอักษร สีปุ่มหลัก สีเตือน · daisyUI มีธีมสำเร็จรูปให้หลายสิบชุด
แต่จะใส่ลงไฟล์ CSS เฉพาะธีมที่เรา "เปิด" · เปิดแล้วจะเลือกใช้ธีมไหน ก็แค่ติดป้าย data-theme="ชื่อธีม" ที่แท็ก <html>
เปิดไฟล์ app/assets/tailwind/application.css · ตอนนี้บรรทัดที่ 3 เป็นแบบนี้:
@plugin "./daisyui.mjs";
เปลี่ยนบรรทัดนั้นเป็นบล็อก 3 บรรทัด · ไฟล์ application.css ทั้งไฟล์หลังแก้:
@import "tailwindcss";
@source not "./daisyui{,*}.mjs";
@plugin "./daisyui.mjs" {
themes: light --default, dark --prefersdark;
}
@theme {
--font-sans: "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif;
}
@plugin "./daisyui.mjs" { … }= เปิดใช้ daisyUI จากไฟล์ที่อยู่ข้าง ๆ กัน และส่งการตั้งค่าไปในวงเล็บปีกกา (บรรทัดอื่นมีอยู่แล้ว ไม่ต้องแตะ — บท 128)themes:= รายชื่อธีมที่เปิด คั่นด้วยจุลภาคlight --default= ธีมสว่าง และเป็นธีมตั้งต้น เมื่อ<html>ยังไม่มีป้ายdata-themedark --prefersdark= ธีมมืด และใช้อัตโนมัติถ้าเครื่องของผู้ใช้ตั้งโหมดมืดไว้ (และยังไม่มีป้าย)
⚠️ สไลด์หน้า 30 เขียน @plugin "daisyui" (ไม่มี ./ และ .mjs) · อย่าก็อปแบบนั้น — repo นี้ไม่มี Node ไม่มี package ชื่อ daisyui ให้หา
มีแต่ไฟล์ daisyui.mjs วางไว้ในโฟลเดอร์ · ลองแล้วได้ (ผลจริง ย่อ path):
Error:
┌
│ Error: Can't resolve 'daisyui' in '…/app/assets/tailwind'
└
bin/rails aborted!
…
ธีมทำงานยังไง? daisyUI ไม่ได้เขียนสีลงในคลาสตรง ๆ แต่เขียนเป็นตัวแปรของ CSS (ชื่อขึ้นต้นด้วย --) แล้วให้แต่ละธีมตั้งค่าตัวแปรชุดเดียวกันต่างกัน ·
ตัวอย่างจากไฟล์ CSS ที่ build ได้จริง (ตัดมาบางส่วน):
/* app/assets/builds/tailwind.css — ไฟล์ที่ Tailwind สร้าง (บรรทัดเดียวยาว ๆ ตัดมาให้ดู) */
[data-theme=dark]{color-scheme:dark;--color-base-100:oklch(25.33% .016 252.42);--color-base-200:oklch(23.26% .014 253.1);…
.bg-base-100{background-color:var(--color-base-100)}
[data-theme=dark]{ … }= "ถ้าเจอป้ายdata-theme="dark"ให้ตั้งตัวแปรพวกนี้" ·--color-base-100คือตัวแปรชื่อ "สีพื้นระดับ 100" ·color-scheme:dark= บอกเบราว์เซอร์ให้วาดของที่มันวาดเอง (แถบเลื่อน · ช่องกรอก) เป็นโทนมืดด้วย.bg-base-100{background-color:var(--color-base-100)}= คลาสbg-base-100ไม่ได้รู้ว่าเป็นสีอะไร มันแค่บอกว่า "ใช้ค่าในตัวแปรนั้น" ·var(…)= อ่านค่าตัวแปร- เปลี่ยนป้ายเป็น dark → ตัวแปรเปลี่ยนค่า → ทุกที่ที่ใช้
bg-base-100เปลี่ยนสีพร้อมกันทันที โดยไม่มีใครต้องแก้ HTML สักบรรทัด
ถ้าเคยทำ บท 81 (design token ของ shadcn) นี่คือกลไกเดียวกันเป๊ะ —
ที่นั่นใช้คลาส .dark ประกาศ --primary ซ้ำ · ที่นี่ใช้ป้าย data-theme แทน
ธีมคือการเปลี่ยนธีมทั้งร้านด้วยป้ายเดียวที่หน้าประตู · ร้านเตรียมชุดของไว้แล้วสองชุด: "ชุดกลางวัน" (ผ้าปูขาว ไฟสว่าง) กับ "ชุดกลางคืน" (ผ้าปูเข้ม ไฟสลัว) ·
ป้ายหน้าประตู (<html data-theme>) เขียนว่า "กลางคืน" เมื่อไหร่ พนักงานทุกคนหยิบของชุดกลางคืนพร้อมกัน ·
คนจัดจานไม่ต้องรู้ว่าชุดไหนสีอะไร แค่หยิบ "ผ้าปูโต๊ะประจำร้าน" (bg-base-100) ตามป้าย ·
ส่วน themes: ใน CSS คือรายการชุดที่ร้านซื้อมาเก็บไว้ — ชุดที่ไม่ได้ซื้อ ต่อให้แขวนป้ายชื่อมันก็ไม่มีของให้หยิบ
ที่ไม่เหมือนกัน: ร้านจริงถ้าแขวนป้ายชื่อชุดที่ไม่มี พนักงานจะงงแล้วมาถาม · CSS ไม่ถามอะไรเลย มันใช้ชุดตั้งต้น (light) เงียบ ๆ ไม่มี error ที่ไหน (ภารกิจท้ายบทจะได้เห็นกับตา) · และป้ายนี้อยู่ในเบราว์เซอร์ของลูกค้าแต่ละคน ไม่ใช่ป้ายเดียวของทั้งร้าน — คนหนึ่งเลือกมืด อีกคนยังเห็นสว่างได้
- theme ·
data-theme - ชุดสีทั้งชุดของเว็บ ·
data-themeคือ attribute บนแท็ก (ส่วนใหญ่<html>) ที่บอก daisyUI ว่าใช้ชุดไหน · บอก AI ได้ว่า "switch daisyUI themes by setting data-theme on the html element, not by adding a dark class" - CSS variable (custom property)
- ตัวแปรของ CSS ชื่อขึ้นต้นด้วย
--อ่านค่าด้วยvar(--ชื่อ)· ธีมทุกแบบคือ "ตั้งตัวแปรชุดเดียวกันด้วยค่าต่างกัน" · ถาม senior ได้ว่า "สีนี้มาจาก CSS variable ของธีม หรือเขียนค่าตายตัวไว้" --default·--prefersdark- ป้ายกำกับในรายการ
themes:ของ daisyUI 5 ·--default= ธีมเมื่อไม่มีป้าย ·--prefersdark= ธีมเมื่อเครื่องตั้งโหมดมืด · บอก AI ได้ว่า "we use daisyUI 5 with Tailwind v4: configure themes inside @plugin in CSS, there is no tailwind.config.js"
บันทึกไฟล์ · bin/dev จะสร้าง CSS ใหม่ให้เอง (ดูในหน้าต่างที่ 1) · ถ้าอยากสั่งเองก็ได้:
$ bin/rails tailwindcss:build
≈ tailwindcss v4.3.3
/*! 🌼 daisyUI 5.7.50 */
Done in 92ms
ไฟล์ app/assets/builds/tailwind.css ที่ได้ใหม่ ต่างจากก่อนแก้มากแค่ไหน? (มีธีมใหม่เพิ่มเข้ามากี่ชุด?)
ไม่ต่างเลยสักไบต์ · ผมก็อปไฟล์ CSS ตอนก่อนแก้เก็บไว้ชื่อ before.css แล้วเทียบด้วย cmp (คำสั่งเทียบไฟล์สองไฟล์ · ไม่พิมพ์อะไรเลย = เหมือนกันทุกไบต์):
$ cmp app/assets/builds/tailwind.css before.css
$ grep -o '\[data-theme=[a-z]*\]' app/assets/builds/tailwind.css | sort | uniq -c
1 [data-theme=dark]
2 [data-theme=light]
เพราะค่าตั้งต้นของ daisyUI 5 ก็คือ light --default, dark --prefersdark อยู่แล้ว · บรรทัดที่เราเขียนจึงไม่ได้เปลี่ยนผล
แต่เปลี่ยนความชัด: คนอ่านไฟล์ (และ AI) รู้ทันทีว่าร้านนี้เปิดธีมอะไร และมีที่ให้เติมธีมที่สาม (ภารกิจท้ายบท)
· คำสั่ง grep … | sort | uniq -c = หาข้อความ [data-theme=…] ทุกจุดแล้วนับว่าแต่ละชื่อเจอกี่ครั้ง — ใช้ตรวจว่าธีมไหนอยู่ในไฟล์บ้าง
ขั้น 2 — navbar: แถบเมนูบนสุดที่อยู่ทุกหน้า
แถบเมนูต้องโผล่ทุกหน้า → ที่ของมันคือ layout (จานรองที่ห่อทุกหน้า) · แต่เราจะไม่เขียนลงใน layout ตรง ๆ
ให้แยกเป็น partial ชื่อ shared/_navbar.html.erb ตามข้อกำหนดของสไลด์ — layout จะได้สั้นอ่านง่าย และหาเจอว่าเมนูอยู่ไฟล์ไหน
สร้างไฟล์ใหม่ app/views/shared/_navbar.html.erb (โฟลเดอร์ shared/ มีแล้วจากบท 126):
<%# locals: () -%>
<header class="navbar bg-base-100 shadow-sm">
<div class="flex-1">
<%= link_to "Wongnok Blog", posts_path, class: "btn btn-ghost text-lg sm:text-xl" %>
</div>
<nav class="flex-none flex items-center gap-1">
<%= link_to "บทความ", posts_path, class: "btn btn-ghost btn-sm" %>
<%= link_to "หมวด", categories_path, class: "btn btn-ghost btn-sm" %>
<button type="button" class="btn btn-ghost btn-sm" aria-label="สลับธีมสว่าง/มืด"
data-controller="theme" data-action="theme#toggle">🌓</button>
</nav>
</header>
<%# locals: () -%>= ป้ายของแม่พิมพ์ว่า "ไม่รับของอะไรเลย" (วงเล็บว่าง) · ถ้าใครเผลอยื่นของมา Rails จะฟ้องทันที (บท 123)navbar= ชิ้นแถบเมนูของ daisyUI: เรียงของในแถวเดียว จัดกลางแนวตั้ง มีระยะขอบให้ ·bg-base-100= พื้นสีระดับ 100 ของธีม ·shadow-sm= เงาบาง ๆ ใต้แถบflex-1= กล่องนี้ยืดกินที่ว่างทั้งหมด → ดันเมนูไปชิดขวา ·flex-none= กล่องเมนูไม่ยืด ไม่หด (บท 61 flexbox) ·flex items-center gap-1= เรียงปุ่มในแถวเดียว จัดให้อยู่กลางแนวตั้งเท่ากัน เว้นช่องไฟระหว่างปุ่ม 1 หน่วยbtn= ชิ้นปุ่ม ·btn-ghost= ปุ่มแบบโปร่ง ไม่มีพื้นจนกว่าเอาเมาส์ชี้ ·btn-sm= ปุ่มขนาดเล็ก · ลิงก์ก็ใส่btnได้ หน้าตาจะเป็นปุ่ม แต่ยังเป็นลิงก์text-lg sm:text-xl= ตัวอักษรใหญ่ระดับ lg · จอกว้างตั้งแต่ขนาด sm ขึ้นไป ค่อยใหญ่เป็น xl (sm:คือเงื่อนไขขนาดจอ — บท 72)aria-label= ข้อความบอกโปรแกรมอ่านหน้าจอ (สำหรับคนตาบอด) ว่าปุ่ม 🌓 ทำอะไรdata-controllerกับdata-action= ป้ายที่ผูกปุ่มนี้กับ JavaScript · ตอนนี้ยังไม่มี JavaScript ตัวนั้น กดไปก็ไม่เกิดอะไร — ขั้น 3 จะทำ
แล้วเปิด layout app/views/layouts/application.html.erb เลื่อนลงล่างสุด แก้ 3 จุด · ก่อนแก้:
<body>
<main class="container mx-auto mt-28 px-5 flex">
<%= yield %>
</main>
</body>
หลังแก้:
<body class="min-h-screen bg-base-200">
<%= render "shared/navbar" %>
<main class="container mx-auto px-4 py-8 flex">
<%= yield %>
</main>
</body>
min-h-screen= สูงอย่างน้อยเต็มจอ → สีพื้นเต็มจอแม้หน้าที่เนื้อหาสั้น ·bg-base-200= พื้นเข้มกว่าbase-100หนึ่งระดับ → การ์ดสีbase-100ที่วางบนพื้นจะลอยเด่นขึ้นมา<%= render "shared/navbar" %>= เอาแม่พิมพ์ navbar มาวางตรงนี้ · ไม่ต้องใส่_และ.html.erbRails เติมเองmt-28(ระยะว่างด้านบนที่ scaffold เว้นไว้) ลบทิ้ง เพราะตอนนี้มี navbar แล้ว ·px-4 py-8= ระยะขอบซ้ายขวา 4 หน่วย บนล่าง 8 หน่วย (1 หน่วยของ Tailwind = 0.25rem = 4px → 4 หน่วย = 16px · บท 72)
เปิด http://localhost:3000/posts จะเห็นแถบเมนูบนสุด · ดู HTML ที่เบราว์เซอร์ได้รับจริง (ผลจริง ตัดมาเฉพาะช่วงต้น <body>):
$ curl -s localhost:3000/posts
<!-- BEGIN app/views/layouts/application.html.erb
--><!DOCTYPE html>
<html>
…
<body class="min-h-screen bg-base-200">
<!-- BEGIN app/views/shared/_navbar.html.erb
--><header class="navbar bg-base-100 shadow-sm">
<div class="flex-1">
<a class="btn btn-ghost text-lg sm:text-xl" href="/posts">Wongnok Blog</a>
</div>
<nav class="flex-none flex items-center gap-1">
<a class="btn btn-ghost btn-sm" href="/posts">บทความ</a>
<a class="btn btn-ghost btn-sm" href="/categories">หมวด</a>
<button type="button" class="btn btn-ghost btn-sm" aria-label="สลับธีมสว่าง/มืด"
data-controller="theme" data-action="theme#toggle">🌓</button>
</nav>
</header>
<!-- END app/views/shared/_navbar.html.erb -->
<main class="container mx-auto px-4 py-8 flex">
<!-- BEGIN … -->/<!-- END … -->= ป้ายที่ Rails แปะให้เฉพาะตอน development บอกว่า HTML ช่วงนี้มาจากไฟล์ไหน — สะดวกมากเวลาหาว่าอะไรมาจากแม่พิมพ์ไหนlink_to "บทความ", posts_path, class: "…"กลายเป็น<a class="…" href="/posts">บทความ</a>· ส่วน<button>เราเขียนเป็น HTML ตรง ๆ จึงออกมาเหมือนเดิมทุกตัวอักษร- สังเกต
<html>บรรทัดบน: ไม่มีdata-theme· server ไม่รู้เลยว่าลูกค้าเลือกธีมอะไร — ป้ายนี้จะถูกติดทีหลังโดย JavaScript ในเบราว์เซอร์ (ขั้น 3)
ขั้น 3 — ปุ่มสลับธีม: JavaScript ตัวแรกของเราแบบ Rails (Stimulus)
ปุ่มต้องทำสองอย่าง: ① กดแล้วเปลี่ยนป้าย data-theme บน <html> ② จำไว้ว่าเลือกอะไร เปิดหน้าใหม่แล้วติดป้ายเดิมให้ ·
ทั้งสองอย่างเกิดในเบราว์เซอร์ ไม่เกี่ยวกับ server → ต้องใช้ JavaScript
ถ้าเป็น React (บท 73) เราจะเขียน onClick ในตัว component ·
ฝั่ง Rails ห้ามเขียน <script> ปนใน view (กฎในสไลด์ และ prompts/00-context.md) ·
Rails 8 ใช้ Stimulus แทน: เขียน JavaScript แยกไว้ในไฟล์ของมัน แล้วให้ HTML ติดป้ายบอกว่าจุดไหนต้องการ JavaScript ตัวไหน
· ป้ายนั้นคือ data-controller="theme" ที่เราเขียนไว้ใน navbar แล้ว
Stimulus controller คืออุปกรณ์บนโต๊ะที่ทำงานเองโดยไม่ต้องเรียกครัว — เหมือนสวิตช์หรี่ไฟข้างโต๊ะ ลูกค้ากดเองได้เลย ครัวไม่ต้องรู้ ·
data-controller="theme" คือป้ายเล็ก ๆ ที่ติดบนโต๊ะว่า "ตรงนี้มีอุปกรณ์ชื่อ theme" ·
ทุกครั้งที่พนักงานจัดโต๊ะ (หน้าเว็บโหลด) พนักงานเห็นป้ายแล้วติดตั้งอุปกรณ์ให้ (Stimulus เรียก connect()) ·
data-action="theme#toggle" คือป้ายที่ปุ่มบอกว่า "กดฉันแล้วให้อุปกรณ์ theme ทำงาน toggle"
ที่ไม่เหมือนกัน: สวิตช์ในร้านจริงหรี่ไฟได้แค่โต๊ะตัวเอง · ปุ่มของเราเปลี่ยนป้ายที่ <html> ซึ่งครอบทั้งหน้า — กดจุดเดียวสีเปลี่ยนทั้งเว็บ ·
และอุปกรณ์นี้ไม่ได้ถูกติดตั้งครั้งเดียวแล้วอยู่ตลอด: navbar อยู่ใน <body> ซึ่งถูกวางใหม่ทุกครั้งที่เปลี่ยนหน้า (กลไกละเอียดในบท 130) → connect() จึงรันทุกหน้า (ผมวัดแล้ว และนี่คือสิ่งที่เราต้องการพอดี)
สร้างไฟล์ใหม่ app/javascript/controllers/theme_controller.js · ชื่อไฟล์สำคัญ: theme_controller.js ↔ data-controller="theme" ต้องตรงกัน
(เหมือนชื่อไฟล์ partial กับ render):
import { Controller } from "@hotwired/stimulus"
// ปุ่มสลับธีมสว่าง/มืด (อยู่ใน shared/_navbar.html.erb)
export default class extends Controller {
// โหลดหน้า: ถ้าเคยเลือกธีมไว้ ใส่กลับให้
connect() {
const saved = localStorage.getItem("theme")
if (saved) document.documentElement.dataset.theme = saved
}
// กดปุ่ม: สลับ light ↔ dark แล้วจำไว้
toggle() {
const html = document.documentElement
const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches
const current = html.dataset.theme || (systemDark ? "dark" : "light")
const next = current === "dark" ? "light" : "dark"
html.dataset.theme = next
localStorage.setItem("theme", next)
}
}
อ่านทีละบรรทัด:
import { Controller } from "@hotwired/stimulus"= ดึงแม่แบบControllerมาจาก library Stimulus · library นี้อยู่ในแอปแล้วผ่าน importmap (สมุดรายชื่อ "ชื่อเล่น → ไฟล์อยู่ไหน" — บท 127) ไม่ต้อง npm installexport default class extends Controller {= สร้าง class ใหม่ที่สืบทอดจาก Controller (เหมือนclass PostsController < ApplicationControllerใน Ruby) ·export default= ไฟล์นี้ส่ง class นี้ออกไปเป็นของหลัก (เหมือนexport default function Page()ใน Next.js) · ไม่ต้องตั้งชื่อ class เพราะ Stimulus ใช้ชื่อไฟล์แทนconnect() { … }= method ที่ Stimulus เรียกเอง เมื่อเจอ element ที่มีdata-controller="theme"บนหน้า · คล้ายuseEffect(() => { … }, [])ที่รันหลังขึ้นจอconst saved = localStorage.getItem("theme")= อ่านค่าที่เคยจดไว้ชื่อ"theme"· ถ้าไม่เคยจด ได้null·const= ตัวแปรที่ตั้งครั้งเดียวไม่เปลี่ยนif (saved) document.documentElement.dataset.theme = saved= ถ้ามีค่า ติดป้ายให้ ·document= หน้าเว็บที่เปิดอยู่ทั้งหน้า (ของที่ JavaScript ใช้หยิบแท็กต่าง ๆ) ·document.documentElement= แท็ก<html>ตัวนอกสุด ·.dataset.theme= attributedata-theme(เบราว์เซอร์แปลงdata-xxxเป็น.dataset.xxxให้)toggle() { … }= method ที่ป้ายdata-action="theme#toggle"เรียก · ชื่อtoggleเราตั้งเอง จะเรียกอะไรก็ได้ขอให้ตรงกันสองฝั่งwindow.matchMedia("(prefers-color-scheme: dark)").matches= ถามเบราว์เซอร์ว่า "เครื่องนี้ตั้งโหมดมืดอยู่ไหม" ได้true/false·window= ตัวหน้าต่างเบราว์เซอร์ (ของที่รู้เรื่องเครื่องและจอ)html.dataset.theme || (systemDark ? "dark" : "light")= ถ้ามีป้ายอยู่แล้วใช้ป้าย ถ้ายังไม่มี (||= "ถ้าซ้ายว่าง ใช้ขวา") ให้เดาจากเครื่อง ·เงื่อนไข ? ก : ข= ถ้าจริงได้ ก ไม่จริงได้ ข (ตัวเดียวกับที่ใช้ใน JSX)current === "dark" ? "light" : "dark"= ตอนนี้มืดอยู่ → ไปสว่าง · นอกนั้น → ไปมืด ·==== เท่ากันเป๊ะhtml.dataset.theme = next= เปลี่ยนป้าย → สีทั้งเว็บเปลี่ยนทันที (ขั้น 1) ·localStorage.setItem("theme", next)= จดลงสมุดว่าเลือกอะไร
ทำไมต้องถามเครื่องก่อน? ตอนยังไม่เคยกด <html> ไม่มีป้าย · ถ้าเครื่องตั้งโหมดมืด daisyUI จะโชว์มืด (เพราะ --prefersdark) ·
ถ้าโค้ดคิดเอาเองว่า "ไม่มีป้าย = สว่าง" กดครั้งแรกจะได้ "มืด" ซ้ำ = กดแล้วไม่เปลี่ยน · ผมทดสอบใน Chrome ที่ตั้งเครื่องเป็นโหมดมืด: ก่อนกดไม่มีป้ายแต่พื้นมืด → กดครั้งแรกได้ data-theme="light" ถูกต้อง
ไม่ต้องลงทะเบียนอะไรที่ไหน · config/importmap.rb มีบรรทัด pin_all_from "app/javascript/controllers", under: "controllers" อยู่แล้ว =
"ไฟล์ทุกไฟล์ในโฟลเดอร์ controllers ใส่ในสมุดรายชื่อให้หมด" · ตรวจได้ (ผลจริง ตัดมาบรรทัดที่เกี่ยว):
$ bin/importmap json
…
"controllers/hello_controller": "/assets/controllers/hello_controller-708796bd.js",
…
"controllers/theme_controller": "/assets/controllers/theme_controller-5a29af2b.js"
…
-5a29af2b คือเลขรุ่นท้ายชื่อไฟล์จากบท 127 (ถ้าไฟล์ของคุณตรงกับข้างบนทุกตัวอักษร เลขจะตรงกัน) ·
hello_controller คือตัวอย่างที่ Rails ให้มาตั้งแต่สร้างแอป
ทำไมไม่มี disconnect() (method เก็บกวาดตอนอุปกรณ์ถูกถอด — คล้าย cleanup ของ useEffect)? เพราะเราไม่ได้ตั้งนาฬิกาหรือดักฟังอะไรเอง ·
การกดปุ่มผูกผ่าน data-action ซึ่ง Stimulus ถอดให้เอง · connect · disconnect · ป้ายแบบอื่น ๆ ของ Stimulus ละเอียดในบท 134 — วันนี้รู้แค่นี้พอใช้
localStorage คือสมุดโน้ตเล่มเล็กที่ลูกค้าพกติดกระเป๋ามาเอง เขียนไว้ว่า "ร้านนี้ ชอบนั่งโซนไฟสลัว" ·
ทุกครั้งที่มาร้านนี้ พนักงานเปิดสมุดของลูกค้าดูแล้วจัดโต๊ะให้ตามนั้น (connect() อ่าน getItem) · ร้านไม่ต้องจดอะไรเลย
แล้วมันขัดกับเรื่อง "สมุดสต็อกเล่มที่สองบนโต๊ะลูกค้า" (บั๊ก sync — บท 122) ไหม? ไม่ขัด เพราะธีมเป็นความชอบของลูกค้าคนนั้น ไม่ใช่ข้อมูลของร้าน · ไม่มีใครอื่นต้องเห็นตรงกัน · บทความ หมวด คอมเมนต์ ยังต้องอยู่ที่ server ชุดเดียวเหมือนเดิม
ที่ไม่เหมือนกัน: สมุดนี้ผูกกับเบราว์เซอร์ + เว็บนี้ ไม่ได้ผูกกับตัวคน — เปิดจากมือถือ เปลี่ยนเบราว์เซอร์ หรือโหมดไม่ระบุตัวตน = สมุดเล่มใหม่ว่าง ๆ ·
และร้าน (server) มองไม่เห็นสมุดนี้เลย นั่นคือเหตุผลที่ curl ได้ <html> ไม่มีป้าย
- Stimulus controller
- ไฟล์ JavaScript ใน
app/javascript/controllers/ชื่อxxx_controller.jsที่ผูกกับ HTML ผ่านป้าย · คนละอย่างกับ controller ของ Rails (ฝั่ง server) แม้ชื่อเหมือน · บอก AI ได้ว่า "write a Stimulus controller instead of an inline script tag; no npm, we use importmap" data-controller·data-actiondata-controller="theme"= จุดนี้ใช้theme_controller.js·data-action="theme#toggle"= เมื่อเกิดเหตุการณ์ ให้เรียก methodtoggle· บนปุ่มไม่บอกเหตุการณ์ = คลิก (เขียนเต็มคือclick->theme#toggle) · ถาม senior ได้ว่า "ปุ่มนี้ผูก action กับ controller ตัวไหน"connect()- lifecycle callback (method ที่ระบบเรียกเองตามจังหวะชีวิตของ element) ของ Stimulus · รันเมื่อ element ที่มีป้ายโผล่บนหน้า · ถาม AI ได้ว่า "is it safe to read localStorage in connect(), or will it run more than once?" (คำตอบ: รันทุกครั้งที่ element ใหม่โผล่ ต้องเขียนให้รันซ้ำได้ — ของเราตั้งค่าเดิมซ้ำ ไม่เป็นไร)
localStorage·prefers-color-schemelocalStorage= ที่เก็บข้อความเล็ก ๆ ในเบราว์เซอร์ แยกตามเว็บ อยู่ข้ามการ refresh และปิดเปิดเบราว์เซอร์ ·prefers-color-scheme= ค่าโหมดสว่าง/มืดที่ผู้ใช้ตั้งในเครื่อง · บอก AI ได้ว่า "persist the chosen theme in localStorage and fall back to prefers-color-scheme"
บันทึก แล้วกลับไปหน้า /posts กด refresh หนึ่งครั้ง (ให้ไฟล์ JS ใหม่โหลด) แล้วกด 🌓
ตอนนี้เราแก้แค่ navbar กับ layout · หน้า /posts ยังเป็นคลาสสีตายตัวของ scaffold (bg-blue-600 · text-gray-700 · bg-gray-100) อยู่เต็มไปหมด
เครื่องตั้งโหมดสว่าง · กด 🌓 หนึ่งครั้ง · อะไรเปลี่ยนเป็นมืด อะไรค้างสีเดิม?
ผมเปิด Chrome จริง วัดสีของทุก element ที่มองเห็น ก่อนและหลังกด แล้วนับตัวที่สีไม่เปลี่ยน ·
(getComputedStyle = คำสั่ง JavaScript ที่ถามเบราว์เซอร์ว่า "แท็กนี้ตอนนี้ใช้สีอะไรจริง ๆ" · element = แท็กหนึ่งตัวบนหน้า)
before theme: (none)
after theme: dark ls: dark
checked 78 unchanged 35
- bg a.shrink-0.rounded-md.px-3.5.py-2.5.bg-blue-600… [เขียนบทความใหม่] oklch(0.546 0.245 262.881)
- color span.font-medium.text-blue-700 [เครื่องดื่ม] oklch(0.488 0.243 264.376)
- color p.mt-2.text-gray-700 [นมสีชมพูจากน้ำแดง หว] oklch(0.373 0.034 259.733)
- bg a.rounded-md.px-3.py-2.bg-gray-100… [ถัดไป →] oklch(0.967 0.003 264.542)
…
body oklch(0.98 0 0) -> oklch(0.2326 0.014 253.1)
อ่านผลยังไง: บรรทัดที่ขึ้นต้นด้วย - คือตัวที่ค้าง · bg = สีพื้นค้าง / color = สีตัวอักษรค้าง ·
ต่อด้วย ชื่อแท็ก.คลาส.คลาส (เช่น a.shrink-0… = แท็ก <a> ที่มีคลาส shrink-0 ฯลฯ) · […] = ข้อความในแท็กนั้น · ท้ายสุด = สีที่ค้างอยู่ ·
บรรทัดสุดท้าย = สีพื้นของ <body> ก่อน -> หลัง
เปลี่ยน: พื้นทั้งหน้า (bg-base-200) · navbar (bg-base-100) · หัวข้อ "บทความทั้งหมด" และชื่อบทความ — เพราะไม่ได้ระบุสีเลย จึงใช้สีตัวอักษรของธีมตาม <body> ·
localStorage จดว่า dark แล้ว (ls: dark)
ค้าง 35 จุดจาก 78: ปุ่ม "เขียนบทความใหม่" ยังน้ำเงินตัวขาว · ชื่อหมวดยังน้ำเงิน · คำโปรยบทความ text-gray-700 เป็นสีเทาเข้มบนพื้นมืด — แทบอ่านไม่ออก ·
ปุ่ม "ถัดไป" เป็นกล่องเทาอ่อนสว่างโร่กลางพื้นมืด · (นอกจาก 35 จุดนี้ ซึ่งนับแค่สีพื้นกับสีตัวอักษร) เส้นคั่น border-gray-200 ก็ยังเป็นสีเทาอ่อนเกือบขาวเหมือนเดิม จึงเห็นเป็นเส้นขาวชัดบนพื้นมืด
นี่คือข้อ "AI พลาดประจำ" ข้อ 3 ของสไลด์หน้า 34 ที่เห็นกับตา: สีที่เขียนชื่อสีตรง ๆ ไม่รู้จักธีม — ขั้น 4–5 คือไล่เปลี่ยนให้หมด
ขั้น 4 — หน้า posts: เปลี่ยนสีตายตัวเป็นชิ้นของ daisyUI
หลักเดียวของขั้นนี้: ทุกที่ที่เขียนชื่อสีตรง ๆ (blue · gray · red · green) ให้เปลี่ยนเป็นชิ้นของ daisyUI หรือสีตามหน้าที่
· ตอนนี้ใน app/views มีบรรทัดแบบนั้นอยู่ 43 บรรทัด (นับจริงด้วย grep ที่จะใช้ตอนตรวจรับ) · จบขั้น 5 ต้องเหลือ 0
daisyUI คือชุดจานชามสำเร็จรูปที่เข้าชุดกัน: จานการ์ด (card) · ป้ายหมวด (badge) · ปุ่ม (btn) — หยิบมาใช้ได้เลยไม่ต้องปั้นเอง ·
และทุกชิ้นสั่งสีด้วย"สีประจำร้าน": "สีหลักของร้าน" (primary) · "สีผ้าปูโต๊ะ" (base-100) · "สีตัวหนังสือบนผ้าปู" (base-content) ·
เปลี่ยนธีมร้านเมื่อไหร่ "สีหลักของร้าน" ก็เปลี่ยนตาม · ส่วน bg-blue-600 คือการสั่ง"สีน้ำเงินเบอร์ 600" ตรง ๆ — ร้านเปลี่ยนธีมกี่รอบ มันก็น้ำเงินเหมือนเดิม
ที่ไม่เหมือนกัน: ชุดจานจริงต้องเลือกระหว่างชุดสำเร็จกับปั้นเอง · daisyUI ผสมได้ — ข้างในมันก็คือคลาส Tailwind ชุดเดิม
เขียน btn btn-primary w-full sm:w-auto ได้สบาย (ชิ้นสำเร็จ + ปรับขนาดเอง) · ที่ห้ามคือผสมสีตายตัว ไม่ใช่ห้ามผสมคลาส Tailwind
- component class · modifier
- component class = คลาสชิ้นสำเร็จของ daisyUI (
btn·card·badge·alert·join) · modifier = คลาสที่ต่อท้ายเพื่อปรับชิ้นนั้น (btn-primary·btn-sm·btn-ghost) ต้องใช้คู่กับตัวหลักเสมอ · บอก AI ได้ว่า "use daisyUI component classes (card, badge, btn) with modifiers; keep Tailwind utilities only for layout and spacing" - semantic color
- สีที่ตั้งชื่อตามหน้าที่ (
primary·error·base-100·base-content) แทนชื่อสี · เปลี่ยนตามธีมอัตโนมัติ · ใส่ใน prompt ได้ว่า "use daisyUI semantic colors only (primary, base-100, base-content); never hard-coded palette colors like bg-blue-600"
ไฟล์ app/views/posts/_post.html.erb — แทนทั้งไฟล์
<%# locals: (post:, full: false) -%>
<article id="<%= dom_id post %>" class="card bg-base-100 shadow-sm">
<div class="card-body">
<p class="flex flex-wrap items-center gap-2 text-sm text-base-content/70">
<span class="badge badge-primary"><%= post.category.name %></span>
<%= time_ago_in_words(post.created_at) %> ago
</p>
<% if full %>
<h1 class="card-title text-3xl"><%= post.title %></h1>
<p class="leading-relaxed whitespace-pre-line"><%= post.description %></p>
<% else %>
<h2 class="card-title">
<%= link_to post.title, post, class: "link link-hover" %>
</h2>
<p class="text-base-content/80"><%= truncate(post.description, length: 80, separator: " ") %></p>
<% end %>
</div>
</article>
Ruby/ERB ทุกบรรทัดเหมือนบท 126 (ป้าย locals · dom_id · if full) · เปลี่ยนแค่โครงกับคลาส:
ห่อเนื้อหาด้วย <div class="card-body"> เพิ่มหนึ่งชั้น · ตัด · หน้าเวลาออก (badge แยกตัวเองชัดแล้ว) · ตัด mt-… ออก (card-body เว้นระยะให้เอง)
| คลาส | ทำอะไร | แทนของเดิม |
|---|---|---|
card · card-body | จานการ์ด: มุมโค้ง · card-body = ไส้การ์ดที่มีระยะขอบด้านในและเว้นระยะระหว่างบรรทัดให้ | py-5 border-b border-gray-200 |
card-title | หัวข้อในการ์ด ตัวหนา ขนาดพอดี · หน้า show ต่อ text-3xl ให้ใหญ่ขึ้น | text-xl font-semibold · text-3xl font-bold |
leading-relaxed whitespace-pre-line | (ของเดิมบท 126) ระยะห่างบรรทัดหลวมขึ้น · ขึ้นบรรทัดใหม่ตามที่ผู้เขียนกด Enter ในเนื้อหา | (ไม่เปลี่ยน) |
badge · badge-primary | ป้ายเล็กมุมมน · พื้นสีหลักของธีม ตัวอักษรสีคู่กัน | font-medium text-blue-700 |
link · link-hover | ลิงก์ของ daisyUI · link-hover = ขีดเส้นใต้เฉพาะตอนเอาเมาส์ชี้ | hover:underline |
text-base-content/70 · /80 | สีตัวหนังสือของธีม จางลงเหลือ 70% / 80% (/70 = ความทึบ) · ใช้กับข้อความรอง (บรรทัดเวลา · คำโปรย) | text-gray-500 · text-gray-700 |
flex flex-wrap items-center gap-2 | เรียงป้ายกับเวลาในแถว จอแคบให้ขึ้นบรรทัดใหม่ได้ เว้นช่องไฟ 2 | (ใหม่) |
ไฟล์ app/views/posts/index.html.erb — แทนทั้งไฟล์
<% content_for :title, "บทความทั้งหมด" %>
<div class="w-full max-w-3xl mx-auto">
<% if notice.present? %>
<p class="alert alert-success mb-5" id="notice"><%= notice %></p>
<% end %>
<div class="flex justify-between items-center gap-4 mb-6">
<h1 class="font-bold text-3xl sm:text-4xl">บทความทั้งหมด</h1>
<%= link_to "เขียนบทความใหม่", new_post_path, class: "btn btn-primary shrink-0" %>
</div>
<div id="posts" class="space-y-4">
<%= render(@posts) || tag.p("ไม่พบบทความ", class: "text-center text-base-content/60 my-10") %>
</div>
<%= render "shared/pagination", pagy: @pagy %>
</div>
เปลี่ยน 4 จุดจากบท 126:
alert alert-success= กล่องแจ้งเตือนสีเขียว "สำเร็จ" ของธีม (แทนbg-green-50 text-green-500 …)btn btn-primary= ปุ่มสีหลักของธีม (แทนbg-blue-600 hover:bg-blue-500 text-white …— daisyUI ทำสีตอนชี้เมาส์ให้เอง) ·shrink-0เดิม = ห้ามปุ่มหดเมื่อจอแคบmb-6= เว้นใต้หัวข้อ ·space-y-4= เว้นระยะ 4 ระหว่างการ์ดแต่ละใบ (ไม่งั้นการ์ดติดกัน)text-base-content/60= สีตัวหนังสือของธีมจาง 60% (แทนtext-gray-500)
ไฟล์ app/views/posts/show.html.erb — แทนทั้งไฟล์
<% content_for :title, @post.title %>
<div class="w-full max-w-3xl mx-auto">
<% if notice.present? %>
<p class="alert alert-success mb-5" id="notice"><%= notice %></p>
<% end %>
<nav class="breadcrumbs text-sm mb-2" aria-label="breadcrumb">
<ul>
<li><%= link_to "บทความทั้งหมด", posts_path %></li>
<li><%= link_to @post.category.name, @post.category %></li>
<li><%= @post.title %></li>
</ul>
</nav>
<%= render @post, full: true %>
<div class="mt-6 flex flex-col sm:flex-row gap-2">
<%= link_to "แก้ไข", edit_post_path(@post), class: "btn" %>
<%= link_to "กลับไปหน้ารวม", posts_path, class: "btn btn-ghost" %>
<%= button_to "ลบบทความ", @post, method: :delete, class: "btn btn-error w-full", data: { turbo_confirm: "ลบบทความนี้จริงไหม?" } %>
</div>
</div>
breadcrumbs= เส้นทาง "อยู่ตรงไหนของเว็บ" ของ daisyUI · ต้องเขียนเป็น<ul><li>แล้ว daisyUI ใส่ลูกศร›คั่นให้เอง → เราลบ›ที่พิมพ์เองในบท 126 ทิ้ง · หัวข้อยาวเกินจอจะเลื่อนแนวนอนได้ในตัวbtnเฉย ๆ = ปุ่มสีกลาง ๆ ของธีม ·btn-ghost= ปุ่มโปร่ง ·btn-error= ปุ่มสี "อันตราย" ของธีม (แทนbg-red-600)flex flex-col sm:flex-row gap-2= จอแคบเรียงปุ่มลงแนวตั้ง · จอตั้งแต่ sm ขึ้นไปเรียงแนวนอน · เว้นช่องไฟ 2 ·w-fullที่ปุ่มลบ (มีตั้งแต่บท 126) =button_toสร้าง<form>ห่อปุ่มไว้ (บท 124) ปุ่มจึงต้องสั่งให้ยืดเต็มกล่องเอง ·turbo_confirm= กล่องถามยืนยันก่อนลบ (ของเดิมบท 126)
HTML ที่ได้จริงของ breadcrumb ในหน้า /posts/1 (ผลจาก curl -s localhost:3000/posts/1 ตัดมาช่วงนี้):
<nav class="breadcrumbs text-sm mb-2" aria-label="breadcrumb">
<ul>
<li><a href="/posts">บทความทั้งหมด</a></li>
<li><a href="/categories/1">อาหารไทย</a></li>
<li>ต้มยำกุ้งน้ำข้น</li>
</ul>
</nav>
ไม่มี › ใน HTML เลย — ลูกศรที่เห็นบนจอมาจาก CSS ของ daisyUI ล้วน ๆ
ไฟล์ app/views/shared/_pagination.html.erb — แทนทั้งไฟล์
<%# locals: (pagy:) -%>
<nav class="mt-8 flex justify-center" aria-label="เปลี่ยนหน้า">
<div class="join">
<% if pagy.previous %>
<%= link_to "← ก่อนหน้า", pagy.page_url(:previous), class: "join-item btn btn-sm" %>
<% else %>
<span class="join-item btn btn-sm btn-disabled">← ก่อนหน้า</span>
<% end %>
<span class="join-item btn btn-sm pointer-events-none">หน้า <%= pagy.page %> / <%= pagy.last %></span>
<% if pagy.next %>
<%= link_to "ถัดไป →", pagy.page_url(:next), class: "join-item btn btn-sm" %>
<% else %>
<span class="join-item btn btn-sm btn-disabled">ถัดไป →</span>
<% end %>
</div>
</nav>
- logic (
if pagy.previous·pagy.page_url) เหมือนบท 126 ทุกบรรทัด · เปลี่ยนแค่คลาส + ห่อด้วย<div class="join"> join+join-item= ต่อปุ่มหลายอันให้ติดกันเป็นแถบเดียว (มุมโค้งเฉพาะหัวกับท้าย) ·flex justify-center= วางแถบไว้กลางbtn-disabled= ปุ่มสีจาง กดไม่ได้ (หน้าแรกไม่มี "ก่อนหน้า") ·pointer-events-none= ช่อง "หน้า 1 / 3" หน้าตาเหมือนปุ่มแต่ไม่ตอบสนองเมาส์
ไฟล์ฟอร์ม app/views/posts/_form.html.erb — แทนทั้งไฟล์
<%= form_with(model: post, class: "contents") do |form| %>
<% if post.errors.any? %>
<div id="error_explanation" role="alert" class="alert alert-error mt-3">
<div>
<h2 class="font-bold">บันทึกไม่ได้ มี <%= post.errors.count %> จุดที่ต้องแก้:</h2>
<ul class="list-disc ml-6">
<% post.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
</div>
<% end %>
<div class="my-5">
<%= form.label :title, "หัวข้อ", class: "block font-medium" %>
<%= form.text_field :title, class: ["input w-full mt-2", {"input-error": post.errors[:title].any?}] %>
</div>
<div class="my-5">
<%= form.label :description, "เนื้อหา", class: "block font-medium" %>
<%= form.textarea :description, rows: 4, class: ["textarea w-full mt-2", {"textarea-error": post.errors[:description].any?}] %>
</div>
<div class="my-5">
<%= form.label :category_id, "หมวด", class: "block font-medium" %>
<%= form.collection_select :category_id, Category.all, :id, :name, { prompt: "เลือกหมวด" }, class: ["select w-full mt-2", {"select-error": post.errors[:category].any?}] %>
</div>
<div class="inline">
<%= form.submit "บันทึก", class: "btn btn-primary w-full sm:w-auto" %>
</div>
<% end %>
alert alert-error= กล่องแจ้งเตือนสี "ผิดพลาด" ของธีม · ต้องห่อ h2 กับ ul ด้วย<div>อีกชั้น เพราะalertของ daisyUI 5 วางลูก ๆ เรียงข้างกัน ในแนวนอน — ไม่ห่อแล้วหัวข้อกับรายการจะอยู่คนละฝั่งinput·textarea·select= ช่องกรอกของ daisyUI (ขอบ มุม สีพื้นตามธีม) ·input-errorฯลฯ = ขอบสีแดงของธีมclass: [ "…", { "input-error": เงื่อนไข } ]= ท่าเดิมของ scaffold: Rails รวมรายการเป็นคลาสเดียว และใส่input-errorเฉพาะเมื่อเงื่อนไขเป็นจริง · ชื่อคลาส"input-error"เขียนเต็มเป็นข้อความในไฟล์ → Tailwind อ่านเจอ ไม่ติดกับดักต่อ string ของบท 128 · เราลบท่อน.none?(ขอบเทา) ทิ้ง เพราะinputมีขอบปกติของธีมอยู่แล้วlabel …, class: "block font-medium"= ป้ายชื่อช่องขึ้นบรรทัดของตัวเอง ตัวหนาปานกลาง- ที่เพิ่มใหม่อีกสองจุด:
role="alert"บนกล่อง error (บอกโปรแกรมอ่านหน้าจอว่าเป็นข้อความเตือน) ·font-boldที่หัวข้อ h2 (ตัวหนา) - ที่ไม่ได้แตะ (ของ scaffold เดิม):
class: "contents"ของฟอร์ม ·<div class="inline">รอบปุ่มบันทึก ·list-disc ml-6(รายการมีจุดนำหน้า เยื้องเข้า)
ไฟล์ new.html.erb กับ edit.html.erb ของ posts — แทนทั้งไฟล์
⚠️ ตั้งแต่ตรงนี้ไป โค้ดหลายก้อนมีบรรทัดแรกเป็น <%# app/views/… %> — ผมเขียนบอกชื่อไฟล์ให้อ่านง่าย ไม่ต้องพิมพ์ลงไฟล์ · เริ่มพิมพ์จากบรรทัดที่สอง
(ถ้าเผลอพิมพ์ไปก็ไม่พัง เพราะ <%# %> คือคอมเมนต์ แต่ไฟล์จะไม่ตรงกับบท 130)
<%# app/views/posts/new.html.erb %>
<% content_for :title, "เขียนบทความใหม่" %>
<div class="md:w-2/3 w-full mx-auto">
<h1 class="font-bold text-4xl">เขียนบทความใหม่</h1>
<%= render "form", post: @post %>
<%= link_to "กลับไปหน้ารวม", posts_path, class: "btn btn-ghost w-full sm:w-auto mt-2 sm:mt-0 sm:ml-2" %>
</div>
<%# app/views/posts/edit.html.erb %>
<% content_for :title, "แก้ไขบทความ" %>
<div class="md:w-2/3 w-full mx-auto">
<h1 class="font-bold text-4xl">แก้ไขบทความ</h1>
<%= render "form", post: @post %>
<%= link_to "ดูบทความนี้", @post, class: "btn btn-ghost w-full sm:w-auto mt-2 sm:mt-0 sm:ml-2" %>
<%= link_to "กลับไปหน้ารวม", posts_path, class: "btn btn-ghost w-full sm:w-auto mt-2 sm:mt-0 sm:ml-2" %>
</div>
เปลี่ยนจากบท 126 แค่สองอย่าง:
เพิ่ม mx-auto (จัดกล่องไว้กลางจอเหมือน index/show) · ลิงก์เป็น btn btn-ghost (แทนกล่องเทา bg-gray-100) ·
ส่วน md:w-2/3 w-full เป็นของเดิม = จอเล็กกว้างเต็ม · จอตั้งแต่ขนาด md ขึ้นไปกว้างสองในสามของจอ ·
mt-2 sm:mt-0 sm:ml-2 = จอแคบเว้นด้านบน (ปุ่มเรียงลง) · จอกว้างเว้นด้านซ้ายแทน (ปุ่มเรียงข้างกัน)
ขั้น 5 — หน้า categories: ทำแบบเดียวกัน + แปลไทย
หน้า /categories ยังเป็น scaffold ภาษาอังกฤษล้วน · ถ้าไม่แก้ เกณฑ์ "สลับธีมแล้วเปลี่ยนทั้งเว็บ" จะไม่ผ่าน (กดเมนู "หมวด" แล้วเจอปุ่มน้ำเงินตายตัว) ·
คลาสเกือบทั้งหมดเจอในขั้น 4 แล้ว (ตัวใหม่มีแค่ divide-y อธิบายใต้โค้ดแรก) · พิมพ์ตามได้เลย
app/views/categories/index.html.erb — แทนทั้งไฟล์
<% content_for :title, "หมวดทั้งหมด" %>
<div class="w-full max-w-3xl mx-auto">
<% if notice.present? %>
<p class="alert alert-success mb-5" id="notice"><%= notice %></p>
<% end %>
<div class="flex justify-between items-center gap-4">
<h1 class="font-bold text-3xl sm:text-4xl">หมวดทั้งหมด</h1>
<%= link_to "เพิ่มหมวด", new_category_path, class: "btn btn-primary shrink-0" %>
</div>
<div id="categories" class="min-w-full divide-y divide-base-300">
<% if @categories.any? %>
<% @categories.each do |category| %>
<div class="flex flex-col sm:flex-row justify-between items-center pb-5 sm:pb-0">
<%= render category %>
<div class="w-full sm:w-auto flex flex-col sm:flex-row gap-2">
<%= link_to "ดู", category, class: "btn btn-sm" %>
<%= link_to "แก้ไข", edit_category_path(category), class: "btn btn-sm" %>
<%= button_to "ลบ", category, method: :delete, class: "btn btn-sm btn-error w-full", data: { turbo_confirm: "ลบหมวดนี้จริงไหม?" } %>
</div>
</div>
<% end %>
<% else %>
<p class="text-center text-base-content/60 my-10">ยังไม่มีหมวด</p>
<% end %>
</div>
</div>
divide-y divide-base-300 = เส้นคั่นระหว่างแถว ใช้สีเส้นระดับ 300 ของธีม (แทน divide-gray-200) ·
gap-2 แทน space-x-2 space-y-2 ของ scaffold ที่ทำให้ปุ่มเหลื่อมกันบนมือถือ
app/views/categories/show.html.erb — แทนทั้งไฟล์
<% content_for :title, @category.name %>
<div class="md:w-2/3 w-full mx-auto">
<% if notice.present? %>
<p class="alert alert-success mb-5" id="notice"><%= notice %></p>
<% end %>
<h1 class="font-bold text-4xl">หมวด: <%= @category.name %></h1>
<%= render @category %>
<div class="flex flex-col sm:flex-row gap-2">
<%= link_to "แก้ไขหมวดนี้", edit_category_path(@category), class: "btn" %>
<%= link_to "กลับไปหน้าหมวด", categories_path, class: "btn btn-ghost" %>
<%= button_to "ลบหมวดนี้", @category, method: :delete, class: "btn btn-error w-full", data: { turbo_confirm: "ลบหมวดนี้จริงไหม?" } %>
</div>
</div>
new.html.erb · edit.html.erb · _form.html.erb · _category.html.erb ของ categories
<%# app/views/categories/new.html.erb %>
<% content_for :title, "เพิ่มหมวด" %>
<div class="md:w-2/3 w-full mx-auto">
<h1 class="font-bold text-4xl">เพิ่มหมวด</h1>
<%= render "form", category: @category %>
<%= link_to "กลับไปหน้าหมวด", categories_path, class: "btn btn-ghost w-full sm:w-auto mt-2 sm:mt-0 sm:ml-2" %>
</div>
<%# app/views/categories/edit.html.erb %>
<% content_for :title, "แก้ไขหมวด" %>
<div class="md:w-2/3 w-full mx-auto">
<h1 class="font-bold text-4xl">แก้ไขหมวด</h1>
<%= render "form", category: @category %>
<%= link_to "ดูหมวดนี้", @category, class: "btn btn-ghost w-full sm:w-auto mt-2 sm:mt-0 sm:ml-2" %>
<%= link_to "กลับไปหน้าหมวด", categories_path, class: "btn btn-ghost w-full sm:w-auto mt-2 sm:mt-0 sm:ml-2" %>
</div>
<%# app/views/categories/_form.html.erb %>
<%= form_with(model: category, class: "contents") do |form| %>
<% if category.errors.any? %>
<div id="error_explanation" role="alert" class="alert alert-error mt-3">
<div>
<h2 class="font-bold">บันทึกไม่ได้ มี <%= category.errors.count %> จุดที่ต้องแก้:</h2>
<ul class="list-disc ml-6">
<% category.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
</div>
<% end %>
<div class="my-5">
<%= form.label :name, "ชื่อหมวด", class: "block font-medium" %>
<%= form.text_field :name, class: ["input w-full mt-2", {"input-error": category.errors[:name].any?}] %>
</div>
<div class="my-5">
<%= form.label :slug, "slug (ภาษาอังกฤษตัวเล็ก a-z 0-9 และขีด)", class: "block font-medium" %>
<%= form.text_field :slug, class: ["input w-full mt-2", {"input-error": category.errors[:slug].any?}] %>
</div>
<div class="inline">
<%= form.submit "บันทึก", class: "btn btn-primary w-full sm:w-auto" %>
</div>
<% end %>
บรรทัดแรกที่บอกชื่อไฟล์ ไม่ต้องพิมพ์ เหมือนเดิม · _category.html.erb แก้แค่ป้ายสองบรรทัด
(Name: → ชื่อหมวด: · Slug: → slug:) ส่วนอื่นไม่แตะ:
<strong class="block font-medium mb-1">ชื่อหมวด:</strong>
…
<strong class="block font-medium mb-1">slug:</strong>
สุดท้าย ข้อความแจ้งหลังบันทึกใน app/controllers/categories_controller.rb แปลเป็นไทยให้ตรงกับ posts (แก้แค่ข้อความในเครื่องหมายคำพูด 3 จุด):
format.html { redirect_to @category, notice: "สร้างหมวดแล้ว" }
…
format.html { redirect_to @category, notice: "บันทึกการแก้ไขแล้ว", status: :see_other }
…
format.html { redirect_to categories_path, notice: "ลบหมวดแล้ว", status: :see_other }
(เดิมคือ "Category was successfully created." · "…updated." · "…destroyed." · ข้อนี้ไม่แก้ก็ไม่พังอะไร แต่แก้เพื่อให้โค้ดตรงกับบทถัดไป)
ตรวจรับตาม checklist ของสไลด์หน้า 34 — ทีละข้อ
อย่าตรวจด้วย "ดูแล้วน่าจะได้" · ทุกข้อมีวิธีที่วัดได้ ·
ผมตรวจทุกข้อด้วย Chrome จริงผ่านสคริปต์ (วัดสีทุก element แทนตา) · คุณตรวจเองได้ด้วย DevTools ของ Chrome
(กด F12 หรือ Cmd+Option+I บน Mac · แท็บ Console = ช่องพิมพ์ JavaScript สั่งหน้านั้นได้ตรง ๆ)
ข้อ 1 · สลับธีมแล้วเปลี่ยนทั้งเว็บ ไม่ใช่แค่บางส่วน
ตรวจด้วยตาทีละหน้า (posts · show · new · categories) แล้วยืนยันด้วยคำสั่งค้นหาสีตายตัวที่เหลือ:
$ grep -rnE "(bg|text|border|divide)-(gray|blue|red|green)-" app/views
$
ไม่มีผลลัพธ์ = ไม่เหลือสีตายตัวสักบรรทัด (ก่อนเริ่ม Lab คำสั่งเดียวกันได้ 43 บรรทัด) · -E = ใช้รูปแบบค้นหาแบบมี ( | ) ได้ · -rn = ค้นทุกไฟล์ในโฟลเดอร์ บอกเลขบรรทัด
ผลวัดจริงใน Chrome (เครื่องตั้งโหมดสว่าง · กด 🌓 ครั้งเดียว · นับ element ที่สีพื้นหรือสีตัวอักษรไม่เปลี่ยน):
| หน้า | element ที่ตรวจ | ไม่เปลี่ยนตามธีม |
|---|---|---|
/posts | 89 | 0 |
/posts/1 | 28 | 2 — ปุ่ม "ลบบทความ" (พื้น + ตัวอักษร) |
/posts/new | 23 | 0 |
/categories | 69 | 10 — ปุ่ม "ลบ" 5 ปุ่ม × (พื้น + ตัวอักษร) |
⚠️ ปุ่มลบสีแดงไม่เปลี่ยน — ไม่ใช่บั๊กของคุณ · ผมวัดสี btn-error ได้ oklch(0.71 0.194 13.428) ทั้งธีม light และ dark ·
แปลว่าธีมสองชุดนี้ตั้ง "สีอันตราย" เป็นค่าเดียวกัน (กล่อง alert-success สีเขียวก็เหมือนกัน) · มันยังเป็น semantic color อยู่ —
ถ้าเปลี่ยนเป็นธีมอื่น (เช่น cupcake ในภารกิจ) สีจะเปลี่ยนตาม · ตัวที่ต้องสงสัยคือคลาสที่มีชื่อสีอยู่ในตัว ไม่ใช่ตัวที่สีบังเอิญเท่าเดิม
ตรวจเองใน Console: พิมพ์ getComputedStyle(document.body).backgroundColor (= "พื้นของ <body> ตอนนี้สีอะไรจริง ๆ") แล้วกด Enter · ก่อนกด 🌓 ได้ oklch(0.98 0 0) · หลังกดได้ oklch(0.2326 0.014 253.1) (ค่าของผม — ถ้าไฟล์ตรงกันของคุณก็ควรได้เท่านี้)
ข้อ 2 · refresh แล้วธีมที่เลือกยังอยู่
กดให้เป็นมืด แล้วกด refresh · ผลวัดจริงใน Chrome (reload แบบไม่ใช้ของในแคช):
before: dark dark
after reload: dark oklch(0.2326 0.014 253.1) <html data-theme="dark">…
- บรรทัดแรก = ป้าย
data-themeกับค่าในlocalStorageก่อน refresh · บรรทัดสอง = หลัง refresh ป้ายยังเป็นdarkพื้นยังมืด - ลำดับที่เกิด: HTML ใหม่มา (ไม่มีป้าย) → Stimulus เจอปุ่มใน navbar →
connect()อ่านสมุด → ติดป้ายกลับ - ตรวจเอง: DevTools แท็บ Application → Local Storage →
http://localhost:3000จะเห็นแถวtheme|dark· หรือพิมพ์ใน ConsolelocalStorage.getItem("theme") - กดลิงก์ "หมวด" ใน navbar ก็ยังมืด — ผมวัดแล้วว่าหน้าไม่ได้โหลดใหม่ทั้งหน้า และ
<html>ตัวเดิมยังอยู่ (กลไกนี้ชื่อ Turbo Drive — เปลี่ยนเฉพาะ<body>· บท 130)
📝 ข้อจำกัดที่ควรรู้: ตอนกด refresh เบราว์เซอร์ได้ HTML ที่ยังไม่มีป้ายก่อน แล้ว JavaScript ค่อยติดป้ายตามมา · บนเครื่องเร็วเสร็จก่อนวาดจอ แต่เครื่องช้าอาจเห็นสีสว่างแวบหนึ่งก่อนเป็นมืด · วิธีแก้จริงคือให้ server ติดป้ายมาตั้งแต่ HTML แรก (เก็บธีมใน cookie — ของที่ server อ่านได้) ซึ่งเกินขอบ Lab นี้
ข้อ 3 · รัน assets:precompile แล้วสียังอยู่ครบ
สไลด์บอกว่า "ข้อนี้คือข้อที่คนลืมตรวจ" · assets:precompile คือการซ้อมเตรียมไฟล์แบบขึ้นเครื่องจริงบนเครื่องเรา (บท 127) ·
ถ้าคลาสไหนหลุดจาก CSS ตอนซ้อม ก็จะหลุดบนเครื่องจริงด้วย · ผลจริง:
$ bin/rails assets:precompile
≈ tailwindcss v4.3.3
/*! 🌼 daisyUI 5.7.50 */
Done in 92ms
Warning: You are precompiling assets in development. Rails will not serve any changed assets until you delete public/assets/.manifest.json
บรรทัด Warning: แปลว่า "คุณกำลัง precompile บนเครื่อง development · จากนี้ Rails จะไม่เสิร์ฟไฟล์ที่คุณแก้ใหม่ จนกว่าจะลบ public/assets/.manifest.json" — จำไว้ เดี๋ยวต้องเก็บกวาด ·
ได้ไฟล์ CSS ติดเลขรุ่นใน public/assets/ · ตรวจว่าคลาสหลักที่เราใช้อยู่ในนั้นครบ (ผลจริง):
$ ls public/assets/ | grep tailwind
tailwind-1c1af83f.css
$ grep -o 'navbar\|card-body\|badge-primary\|btn-primary\|breadcrumbs\|join-item' public/assets/tailwind-*.css | sort | uniq -c
1 badge-primary
28 breadcrumbs
1 btn-primary
8 card-body
4 join-item
2 navbar
$ grep -o '\[data-theme=[a-z]*\]' public/assets/tailwind-*.css | sort | uniq -c
1 [data-theme=dark]
2 [data-theme=light]
- อ่านคำสั่ง:
ls public/assets/ | grep tailwind= ดูรายชื่อไฟล์ในโฟลเดอร์ แล้วกรองเฉพาะชื่อที่มีคำว่า tailwind ·\|ในคำสั่ง grep ตัวที่สอง = "หรือ" (หาคำไหนก็ได้ในรายการ) - ทุกชื่อเจออย่างน้อย 1 ครั้ง = มีกฎ CSS ของคลาสนั้นจริง (ตัวเลขไม่ต้องสนใจ — บางคลาสถูกพูดถึงหลายจุดในไฟล์) · ธีม light และ dark อยู่ครบ
tailwind-1c1af83f.css= เลขรุ่นจากเนื้อไฟล์ · ไฟล์คุณตรงกับของผมทุกตัวอักษร → เลขควรตรงกัน- บนเครื่องผมทั้งคำสั่งใช้ราว 1 วินาที (สไลด์บอก 20 วินาที — แอปที่ใหญ่กว่านี้ หรือเครื่องที่รันตรวจอัตโนมัติก่อน deploy ที่เรียกว่า CI จะนานขึ้น)
เสร็จแล้วต้องลบของที่ซ้อมทิ้งทันที:
$ bin/rails assets:clobber
$
⚠️ สองกับดักของคู่นี้ ① ลืม clobber → Rails เสิร์ฟ CSS ตัวที่ซ้อมไว้ใน public/assets ไปเรื่อย ๆ แก้คลาสแล้วไม่เห็นผล (ข้อความ Warning: ข้างบนเตือนตรง ๆ) ·
② clobber ลบ app/assets/builds/tailwind.css ไปด้วย · ถ้าตอนนั้นเปิดแค่ bin/rails server หน้าเว็บจะไม่มี style เลย — ผมดู HTML หลัง clobber แล้ว
บรรทัด <link rel="stylesheet" href="/assets/tailwind-1c1af83f.css" …> หายไปทั้งบรรทัด เหลือแค่ application-8b441ae0.css ·
แก้: ปล่อยให้ bin/dev สร้างใหม่ (แก้ไฟล์ view อะไรก็ได้หนึ่งครั้ง) หรือสั่ง bin/rails tailwindcss:build
🔎 ถ้าคุณลอง grep bg-blue-600 ในไฟล์ CSS แล้วเจอ อย่าตกใจ · มันมาจากไฟล์ prompts/02-styling.md ที่เราจะสร้างข้างล่าง (มีประโยค "ไม่ใช่ bg-blue-600") ·
ผมพิสูจน์แล้ว: ย้ายไฟล์ .md ออกชั่วคราว build ใหม่ → นับได้ 0 · ใส่คืน → นับได้ 1 ·
นี่คือหลักเดียวกับกับดักต่อ string ในบท 128: Tailwind อ่านข้อความในไฟล์ของโปรเจกต์ ไม่ได้รันโค้ด — เห็นชื่อคลาสที่ไหนก็สร้างให้ ไม่สนว่าเป็นไฟล์อะไร
assets:precompile·assets:clobber- precompile = เตรียม CSS/JS แบบ production ไว้ใน
public/assets· clobber = ลบของที่ precompile ไว้ (และ CSS ที่ build ไว้) ทิ้ง · บอก AI ได้ว่า "after changing views, verify with bin/rails assets:precompile, grep the class in public/assets, then run assets:clobber" - production build
- ไฟล์ชุดที่จะขึ้นเครื่องจริง (ไม่มีคนคอย watch · ติดเลขรุ่นแล้ว) · การซ้อม precompile บนเครื่องเราคือการดูของชุดนี้ก่อนลูกค้าเห็น · ถาม senior ได้ว่า "CI (ระบบตรวจอัตโนมัติ) ของเรารัน assets:precompile ก่อน deploy ไหม ถ้าคลาสหายจะรู้ตอนไหน"
ข้อ 4 · ไม่มีไฟล์ใหม่ในโปรเจกต์นอกจาก .erb
ดูไฟล์ใหม่ทั้งหมดก่อน commit (ผลจริง · ?? = ไฟล์ใหม่ที่ git ยังไม่รู้จัก · M = ไฟล์เดิมที่ถูกแก้):
$ git status --short
M app/assets/tailwind/application.css
M app/controllers/categories_controller.rb
M app/views/categories/_category.html.erb
M app/views/categories/_form.html.erb
M app/views/categories/edit.html.erb
M app/views/categories/index.html.erb
M app/views/categories/new.html.erb
M app/views/categories/show.html.erb
M app/views/layouts/application.html.erb
M app/views/posts/_form.html.erb
M app/views/posts/_post.html.erb
M app/views/posts/edit.html.erb
M app/views/posts/index.html.erb
M app/views/posts/new.html.erb
M app/views/posts/show.html.erb
M app/views/shared/_pagination.html.erb
?? app/javascript/controllers/theme_controller.js
?? app/views/shared/_navbar.html.erb
?? prompts/02-styling.md
ไฟล์ใหม่ 3 ไฟล์ · มี .js กับ .md ด้วย → ตามตัวอักษร ข้อนี้ไม่ผ่าน · แต่ลองอ่านเจตนา:
ข้อนี้อยู่คู่กับข้อ "ห้าม" ที่บอกว่าห้ามสร้าง tailwind.config.js · ห้าม npm install / package.json · ห้ามไฟล์ .css ใหม่ —
มันตั้งใจจับ AI ที่ไปสร้างไฟล์ตั้งค่าแบบ Tailwind v3 หรือ Node ตรวจตรงนั้นได้ (ผลจริง):
$ ls tailwind.config.js package.json node_modules
ls: node_modules: No such file or directory
ls: package.json: No such file or directory
ls: tailwind.config.js: No such file or directory
ไม่มีสักไฟล์ = ผ่านตามเจตนา · ส่วน theme_controller.js จำเป็น เพราะข้อกำหนดของใบสั่งเดียวกันให้ "เปลี่ยน data-theme + จำใน localStorage" ซึ่งต้องใช้ JavaScript
และห้ามเขียน <script> ใน view → ที่เดียวที่ถูกคือ Stimulus controller (branch ของอาจารย์ก็มีไฟล์นี้) · .md คือไฟล์ prompt ที่สไลด์ให้เก็บเอง ·
บทเรียนจากข้อนี้: checklist เขียนโดยคน ผิดได้ — ถ้าข้อหนึ่งขัดกับอีกข้อ ให้ถามว่า "ข้อนี้ตั้งใจกันอะไร" แล้วตรวจตรงนั้น
ข้อ 5 · ย่อหน้าต่างเหลือความกว้างมือถือแล้วยังอ่านได้
DevTools → กดไอคอนรูปมือถือ (Cmd+Shift+M) → ตั้งความกว้าง 375 · แล้วพิมพ์ใน Console
document.documentElement.scrollWidth = ความกว้างจริงของเนื้อหาทั้งหน้า · ถ้าได้มากกว่า 375 แปลว่ามีของล้นจอ ต้องเลื่อนซ้ายขวา
ผลวัดจริง 8 หน้า × 2 ธีม (ตัดมาบางหน้า · ทุกหน้าได้เท่ากัน):
dark /posts 375/375
dark /posts?page=3 375/375
dark /posts/1 375/375
dark /categories 375/375
light /posts/new 375/375
light /categories/new 375/375
ตัวเลขซ้าย = scrollWidth · ขวา = ความกว้างจอ · เท่ากันทุกหน้า = ไม่มีอะไรล้น ·
ที่ 375px แถบ navbar ยังอยู่บรรทัดเดียว (เพราะใช้ btn-sm และชื่อเว็บ text-lg) · ปุ่มในหน้า show เรียงลงเต็มกว้าง (flex-col · ขึ้น sm:flex-row เมื่อจอกว้าง)
ใบสั่งงาน AI ของ Lab นี้: prompts/02-styling.md
ตอนนี้คุณทำเองครบแล้ว · เก็บใบสั่งของสไลด์หน้า 34 ไว้ในโฟลเดอร์ prompts/ (ข้าง ๆ 01-views.md ของบท 126) ·
ครั้งหน้าที่ต้องทำงานแบบนี้กับโปรเจกต์อื่น ใช้ไฟล์นี้สั่ง AI แล้วตรวจด้วยวิธีเดียวกับหัวข้อข้างบน · สร้างไฟล์ใหม่ prompts/02-styling.md:
## เป้าหมาย
ทำให้หน้า index กับ show ดูเป็นเว็บจริง
มี navbar และสลับธีมสว่าง/มืดได้
## ข้อกำหนด
- ใช้ component ของ daisyUI (card, navbar, badge)
ไม่เขียน CSS เอง ไม่สร้างไฟล์ .css ใหม่
- ใช้ semantic color (primary, base-100)
ไม่ใช่ bg-blue-600
- navbar เป็น partial shared/_navbar.html.erb
- ปุ่มสลับธีม เปลี่ยน data-theme บน `<html>`
- จำธีมที่เลือกไว้ใน localStorage
- responsive ใช้ได้บนมือถือ
## ห้าม
- สร้าง tailwind.config.js (เราใช้ v4)
- ต่อ string เป็นชื่อคลาส Tailwind
- แนะนำให้ npm install หรือเพิ่ม package.json
- เขียน `<style>` ใน view
## เกณฑ์ตรวจรับ
- [ ] สลับธีมแล้วเปลี่ยนทั้งเว็บ ไม่ใช่แค่บางส่วน
- [ ] refresh แล้วธีมที่เลือกยังอยู่
- [ ] รัน assets:precompile แล้วสียังอยู่ครบ
- [ ] ไม่มีไฟล์ใหม่ในโปรเจคนอกจาก .erb
- [ ] ย่อหน้าต่างเหลือความกว้างมือถือแล้วยังอ่านได้
เนื้อหาตรงกับสไลด์ทุกบรรทัด และเป็นไฟล์เดียวกับใน branch day3-b-done ของอาจารย์ทุกตัวอักษร · ต่างจากภาพสไลด์แค่ backtick ที่ครอบ `<html>` กับ `<style>` (บนสไลด์ไม่เห็น แต่ในไฟล์ของอาจารย์มี) = วิธีเขียนโค้ดในไฟล์ Markdown
วิธีใช้ครั้งหน้า (แบบเดียวกับบท 121): ใน Claude Code พิมพ์ @prompts/02-styling.md ทำตามไฟล์นี้ ·
พอ AI บอกว่าเสร็จ อย่าเชื่อทันที — ไล่ตรวจ 5 ข้อด้วยวิธีในหัวข้อตรวจรับข้างบน แล้วเทียบกับตาราง "AI พลาดประจำ" ข้างล่าง
ที่ AI พลาดประจำ (สไลด์หน้า 34) — อาการ และตรวจยังไง
| AI ทำ | ทำไมผิด | ตรวจเจอด้วย |
|---|---|---|
สั่งให้สร้าง tailwind.config.js | ตอบแบบ Tailwind v3 · v4 ตั้งค่าใน CSS (@plugin · @theme) — ไฟล์นั้นจะถูกเมินเฉย ๆ หรือทำให้คนตามงานงง | ls tailwind.config.js ต้องไม่มี |
บอกให้ npm install daisyui · เขียน @plugin "daisyui" | repo นี้ไม่มี Node · daisyUI เป็นไฟล์ daisyui.mjs ในโฟลเดอร์ · build จะพังด้วย Can't resolve 'daisyui' | ls package.json ต้องไม่มี · bin/rails tailwindcss:build ต้องผ่าน |
ใช้ bg-blue-600 · text-gray-700 | สีตายตัว ไม่รู้จักธีม → สลับแล้วค้าง (เห็นใน predict ขั้น 3: ค้าง 35 จุด) | grep หาสีตายตัว ข้อ 1 ต้องว่าง |
(เพิ่มเอง) เขียน <script> ใน layout เพื่อสลับธีม | ขัดข้อห้าม · โค้ดกระจายไม่มีที่อยู่ · ทางที่ถูกคือ Stimulus controller | grep -rn "<script" app/views ต้องไม่เจอใน view ของเรา |
เก็บเข้าโปรเจกต์
$ git add .
$ git commit -m "styling: daisyUI + navbar + ปุ่มสลับธีม"
[my-day3 7ba4b9f] styling: daisyUI + navbar + ปุ่มสลับธีม
19 files changed, 186 insertions(+), 108 deletions(-)
create mode 100644 app/javascript/controllers/theme_controller.js
create mode 100644 app/views/shared/_navbar.html.erb
create mode 100644 prompts/02-styling.md
เลข commit (7ba4b9f) ของคุณจะต่าง · 19 files changed, 186 insertions(+), 108 deletions(-) ควรตรงกัน ถ้าพิมพ์ครบทุกบรรทัด ·
ผมเทียบไฟล์ทั้งโปรเจกต์หลัง commit นี้กับโค้ดที่บท 130 ใช้แล้ว ตรงกันทุกไฟล์ · create mode 100644 = ไฟล์ใหม่ 3 ไฟล์ที่ตรงกับข้อ 4 ข้างบน
🎯 ภารกิจ: เปิดธีมที่สาม cupcake (~10 นาที · ทำหลัง commit แล้วเท่านั้น)
- ใน
app/assets/tailwind/application.cssเติม, cupcakeต่อท้ายรายการthemes:(ก่อน;) · บันทึก - รอ
bin/devbuild ใหม่ (หรือสั่งbin/rails tailwindcss:build) แล้วนับธีมด้วยgrep -o '\[data-theme=[a-z]*\]' app/assets/builds/tailwind.css | sort | uniq -c - เปิด
/posts· DevTools Console พิมพ์localStorage.setItem("theme", "cupcake")แล้วกด refresh - กด 🌓 สองครั้ง · สังเกตว่าแต่ละครั้งได้ธีมอะไร · คิดว่าทำไม cupcake หายไปจากการกดปุ่ม (ดูบรรทัด
const next = …) - ลองพิมพ์
localStorage.setItem("theme", "synthwave")(ธีมที่ไม่ได้เปิด) แล้ว refresh · เห็นอะไร? - คืนของ:
git restore app/assets/tailwind/application.css· ConsolelocalStorage.removeItem("theme")·git status --shortต้องว่าง
ควรเห็น: ข้อ 2 มีบรรทัด 1 [data-theme=cupcake] เพิ่มมา · ข้อ 3 พื้นเป็นสีครีม ปุ่มหลักกับป้ายหมวดเป็นสีเขียวมิ้นต์ ปุ่มมนกลม · ข้อ 4 ได้ dark แล้ว light
ถ้าเจออาการนี้ แปลว่า…
- DevTools Elements เห็น
<html data-theme="cupcake">แต่สีเหมือน light เป๊ะ → CSS ไม่มีชุด cupcake: สะกดผิด (ผมลองพิมพ์cupcakแล้ว build ผ่านเงียบ ๆ ไม่มี error ธีมแค่ไม่ถูกสร้าง) หรือbin/devไม่ได้รัน → รัน grep ข้อ 2 ดู - build พังด้วย
Can't resolve 'daisyui'→ เผลอแก้"./daisyui.mjs"เป็น"daisyui"ตามสไลด์ · คืนเป็นแบบเดิม - กดปุ่มแล้วไม่เกิดอะไรเลย → เปิด Console ดู error · หรือชื่อไฟล์ไม่ใช่
theme_controller.js/ ป้ายไม่ใช่data-controller="theme"
ข้อ 1–2 — ผลจริง:
$ git diff
…
@plugin "./daisyui.mjs" {
- themes: light --default, dark --prefersdark;
+ themes: light --default, dark --prefersdark, cupcake;
}
…
$ bin/rails tailwindcss:build
≈ tailwindcss v4.3.3
/*! 🌼 daisyUI 5.7.50 */
Done in 93ms
$ grep -o '\[data-theme=[a-z]*\]' app/assets/builds/tailwind.css | sort | uniq -c
1 [data-theme=cupcake]
1 [data-theme=dark]
2 [data-theme=light]
ข้อ 3–5 — วัดใน Chrome จริง (ป้าย · ค่าในสมุด · สีพื้น · สีปุ่มหลัก):
reload {"theme":"cupcake","saved":"cupcake","body":"oklch(0.93982 0.007 61.449)","primary":"oklch(0.85 0.138 181.071)"}
click1 {"theme":"dark","saved":"dark","body":"oklch(0.2326 0.014 253.1)","primary":"oklch(0.58 0.233 277.117)"}
click2 {"theme":"light","saved":"light","body":"oklch(0.98 0 0)","primary":"oklch(0.45 0.24 277.023)"}
synthwave(not enabled) {"theme":"synthwave","saved":"synthwave","body":"oklch(0.98 0 0)","primary":"oklch(0.45 0.24 277.023)"}
- reload:
connect()อ่านสมุดได้cupcake→ ติดป้ายให้ → สีเปลี่ยนทั้งหน้า · ไม่ต้องแก้ JavaScript สักบรรทัด เพราะ controller แค่ "ก็อปค่าจากสมุดไปติดป้าย" - click1:
current="cupcake"ไม่ใช่"dark"→next="dark"· click2: มืด → สว่าง · cupcake หลุดวงจร เพราะtoggle()รู้จักแค่สองค่า — ถ้าอยากวนสามธีม ต้องแก้toggle()(ลองหลังเรียนบท 134) - synthwave: ป้ายติดแล้ว สมุดจดแล้ว แต่สีพื้นกับปุ่มเท่ากับ light ทุกค่า — ชุดที่ร้านไม่ได้ซื้อ แขวนป้ายไปก็ไม่มีของ (analogy ขั้น 1) · ไม่มี error ใน Console
ข้อ 6 — git restore คืนไฟล์เป็นแบบใน commit ล่าสุด (ไม่มี cupcake) · bin/dev build ใหม่ให้เอง · สมุดใน localStorage ต้องลบเองเพราะ git ไม่รู้จักของในเบราว์เซอร์
Quiz
ข้อ 1 · เพื่อนเพิ่มปุ่ม "แชร์" ใน navbar เขียน class="btn bg-blue-600 text-white" · กดสลับเป็นธีมมืด — ปุ่มแชร์เป็นยังไง?
ตัวเลือกที่ 2 ถูก · predict ในขั้น 3 วัดจริงแล้ว: ปุ่ม bg-blue-600 ได้ oklch(0.546 0.245 262.881) ทั้งก่อนและหลังกด
ตัวเลือกที่ 1 ผิด — daisyUI เปลี่ยนเฉพาะสีที่อ่านจากตัวแปรของธีม · bg-blue-600 เป็นสีของ Tailwind เอง ไม่เกี่ยวกับธีม ·
ตัวเลือกที่ 3 ผิด — btn ให้รูปทรงกับสีตั้งต้น แต่ bg-blue-600 สั่งสีพื้นทับไปแล้ว · ตัวเลือกที่ 4 ผิด — ผสมได้ตามสบาย (btn btn-primary w-full) ที่มีปัญหาคือสีตายตัว ไม่ใช่การผสม
จุดที่ต้องจำ: อยากให้สีตามธีม ต้องเรียกสีตามหน้าที่ (btn-primary · bg-base-100) ไม่ใช่ตามชื่อสี · ตรวจด้วย grep ชื่อสีใน app/views
ข้อ 2 · เลือกธีมมืดแล้วกด refresh ธีมยังมืดอยู่ — อะไรทำให้มันจำได้?
ตัวเลือกที่ 4 ถูก · toggle() จดด้วย localStorage.setItem · หน้าใหม่โหลด → Stimulus เจอปุ่ม → connect() อ่าน getItem → ติดป้าย
ตัวเลือกที่ 1 ผิด — curl ได้ <html> ที่ไม่มี data-theme · server ไม่รู้เลยว่าเราเลือกอะไร ·
ตัวเลือกที่ 2 ผิด — ไฟล์ CSS มีทุกธีมที่เปิด (light กับ dark) เหมือนกันสำหรับทุกคน ไม่ได้จำอะไรของใคร ·
ตัวเลือกที่ 3 ผิด — ไม่มีโค้ดไหนเขียนไฟล์ CSS · และถ้าเปลี่ยน --default จะมืดทุกคน ไม่ใช่เฉพาะคนที่เลือก
จุดที่ต้องจำ: ธีมเก็บในเบราว์เซอร์ของแต่ละคน — เปลี่ยนเครื่องหรือเบราว์เซอร์ = เริ่มใหม่ · ถ้าอยากให้ server รู้ ต้องใช้ cookie (เกินขอบ Lab นี้)
ข้อ 3 · รัน bin/rails assets:precompile ตรวจข้อ 3 ผ่าน แล้วรัน bin/rails assets:clobber · จากนั้นปิด bin/dev เปิดใหม่ด้วย bin/rails server อย่างเดียว — หน้าเว็บเป็นยังไง?
ตัวเลือกที่ 1 ถูก · หลัง clobber ผมดู HTML จริง: บรรทัด <link … href="/assets/tailwind-1c1af83f.css"> หายไปทั้งบรรทัด เพราะ app/assets/builds/tailwind.css ถูกลบ
ตัวเลือกที่ 2 คือความเข้าใจที่คนส่วนใหญ่มี — แต่ของจริง clobber ลบไฟล์ใน builds ด้วย ·
ตัวเลือกที่ 3 คืออาการตอนลืม clobber (ไม่ใช่หลัง clobber) · ตัวเลือกที่ 4 ผิด — ผมได้ 200 หน้าเปิดได้ แค่ไม่มี style เพราะ Rails แค่ไม่ใส่ลิงก์ไฟล์ที่ไม่มีอยู่
จุดที่ต้องจำ: ซ้อม precompile แล้ว clobber ทุกครั้ง · หลัง clobber ให้ bin/dev (หรือ tailwindcss:build) สร้าง CSS ใหม่ก่อนทำงานต่อ
ข้อ 4 · ตรวจข้อ 1 แล้วพบว่าปุ่ม "ลบบทความ" (btn btn-error) สีแดงเท่าเดิมทั้งธีมสว่างและมืด — ควรทำยังไง?
ตัวเลือกที่ 3 ถูก · ผมวัด btn-error ได้ oklch(0.71 0.194 13.428) ทั้งสองธีม = ธีม light/dark ของ daisyUI ตั้งสีนี้เหมือนกัน · ในธีม cupcake มันจะเปลี่ยนตาม
ตัวเลือกที่ 1 คือการถอยกลับไปใช้สีตายตัว — ตอนนี้ดูเหมือนเดิม แต่วันที่เปลี่ยนธีมอื่นมันจะค้าง · ตัวเลือกที่ 2 ผิดข้อห้ามของใบสั่งงานตรง ๆ · ตัวเลือกที่ 4 อ่านเกณฑ์ตามตัวอักษรจนทำลายฟีเจอร์ — เจตนาของเกณฑ์คือ "ไม่มีส่วนไหนล็อกสีไว้" ไม่ใช่ "ทุกพิกเซลต้องเปลี่ยน"
จุดที่ต้องจำ: สีที่บังเอิญเท่าเดิมกับสีที่ล็อกไว้ ดูด้วยตาแยกไม่ออก · ให้ดูที่คลาส — มีชื่อสี (red · blue) = ล็อก · มีชื่อหน้าที่ (error · primary) = ตามธีม
ถามกลับได้เลย
ถ้าสลับธีมแล้วมีบางส่วนค้าง หรือ AI แต่งหน้าให้แล้วคุณไม่แน่ใจว่าผ่านเกณฑ์ไหม — เอาไปถาม senior หรือ AI ได้เลย ·
ส่งไปสามอย่าง: ไฟล์ view ที่สงสัย · ผลของ grep -rnE "(bg|text|border|divide)-(gray|blue|red|green)-" app/views · และ git status --short
· ถ้าปุ่ม 🌓 ไม่ทำงาน แนบข้อความสีแดงใน DevTools Console มาด้วย
ถ้าเปิดสไลด์ดูประกอบ
| หน้า | สไลด์เขียน | ของจริง | ต้องสนใจไหม |
|---|---|---|---|
| 30 | @plugin "daisyui" { … } | repo นี้ไม่มี Node · ต้องเป็น @plugin "./daisyui.mjs" ในไฟล์ app/assets/tailwind/application.css · ก็อปแบบสไลด์แล้ว build พัง Can't resolve 'daisyui' | สนใจ — ขั้น 1 |
| 30 | themes: light --default, dark --prefersdark, synthwave; | ไวยากรณ์ถูก · Lab เปิดแค่ light + dark ตามโจทย์ "สว่าง/มืด" · ซึ่งเป็นค่าตั้งต้นของ daisyUI 5 อยู่แล้ว (CSS เหมือนเดิมทุกไบต์) | แค่รู้ไว้ — เติมธีมได้แบบภารกิจ |
| 34 | เกณฑ์ "ไม่มีไฟล์ใหม่นอกจาก .erb" | ต้องมี theme_controller.js (ปุ่มสลับธีมต้องใช้ JS และห้าม <script> ใน view) และ prompts/02-styling.md | สนใจ — ตรวจตามเจตนา (ข้อ 4) |
| 34 | assets:precompile "ใช้เวลา 20 วินาที" | เครื่องผมราว 1 วินาทีสำหรับแอปนี้ · ทิ้งท้ายด้วย Warning ให้ลบไฟล์รายชื่อ .manifest.json — คือเหตุผลที่ต้อง assets:clobber | แค่รู้ไว้ |
| 34 | ใช้ bg-blue-600 → สลับธีมแล้วไม่เปลี่ยน | จริง · วัดได้ 35 จุดที่ค้างในหน้า /posts ก่อนแก้ขั้น 4 | ตรงกับของจริง |
อ้างอิง
- daisyUI — Themes —
อ่านหัวข้อ Enable a built-in theme ไวยากรณ์
themes: … --default, … --prefersdarkใน@pluginตรงกับขั้น 1 · แล้วเลื่อนไป List of themes ดูชื่อธีมที่ใช้ในภารกิจได้ - daisyUI — Colors —
อ่านตารางรายชื่อสี (
primary·base-100·base-content·errorฯลฯ) และย่อหน้าที่อธิบายว่าทำไมควรใช้ชื่อสีตามหน้าที่แทนชื่อสีของ Tailwind - daisyUI — Card · Navbar ·
Breadcrumbs · Join —
ดูตาราง class name ด้านบนของแต่ละหน้า (
card-body·card-title·join-item) ตรงกับคลาสที่ใช้ในขั้น 2–4 - Stimulus Handbook — Hello, Stimulus —
อ่านทั้งหน้า (สั้น) ตัวอย่างแรกที่ผูก
data-controllerกับdata-actionแบบเดียวกับปุ่ม 🌓 · แล้วดู Actions — Event Shorthand ตารางที่บอกว่า<button>ใช้clickเป็นค่าตั้งต้น - Stimulus Reference — Lifecycle Callbacks —
อ่านตารางแรก ว่า
connect()ถูกเรียกเมื่อไหร่ (และทำไมรันได้หลายครั้ง) - MDN — Window: localStorage ·
HTMLElement: dataset ·
prefers-color-scheme —
อ่านส่วน Description กับ Examples ของแต่ละหน้า ครบทุกคำสั่ง JavaScript ที่ใช้ใน
theme_controller.js - Rails Guides — The Asset Pipeline —
อ่านหัวข้อ 3.3.2 Precompiling Assets เรื่อง precompile และการลบของเก่าด้วย
assets:clobber(ตรวจรับข้อ 3)