跳至主要内容

appfuse-web Changelog

Framework Library 變更日誌(lib/ 框架層)。


[Unreleased]​

Added​

  • NavigationTabs/NavigationTab 提供路由頁籤導覽,與頁內 Tabs 共用變體、密度與選中樣式。 使用 nav/React Router NavLink,保留 href、aria-current、精確比對、修飾鍵/另開分頁、 ref 與歷史導覽;不以頁內 tab/tabpanel 語意替代跨頁連結。

  • /prototype 新增 Prototype 體驗 client(19.0.0-alpha.139)、Provider、工具入口與獨立工具頁;/vite 新增 prototypeToolsPlugin()、createMockAuthMiddleware() 與公開 fixture/lease 型別。 開發服務依原生 HttpOnly Cookie 隔離體驗,提供自然時鐘信箱、情境/故障與重設; generation/revision 與控制佇列拒絕舊工作,閒置與容量逐出回收資源。 discovery 明確區分未安裝與服務/協定錯誤;工具不進正式建置,應用需註冊自己的 fixture。 工具頁以信箱優先、桌面窄設定欄/手機單欄呈現,顯示用途、收件者與狀態; 情境切換與重新開始使用具名確認,送出鎖定、失敗保留與原處重試;故障恢復保留資料。 既有 mockAuthPlugin() 用法保持並接回 Vite 錯誤診斷;工具 discovery 明確回 404, 支援投影應用暫時啟用 MSW;獨立 middleware 可選擇傳入 logger。部署型 Host 不在首版範圍。

  • 新增個人開發的區網 HTTP Session 相容政策(19.0.0-alpha.137): createSessionCoordinator({ browserCompatibility: { mode: 'development-http', environment: 'personal-development' }, ... })。 alpha.137 初版僅在明示政策的非安全 HTTP 頁面選用同分頁佇列;後續開發模式自動政策見 Changed。 公開唯讀 coordinator.diagnostics、SessionBrowserCompatibility、SessionCoordinatorDiagnostics; 降級時警告跨分頁互斥不受保證。新設定不可與既有 crossTab 同時指定。 既有 crossTab: 'single-context' 的佇列與生命週期機制保留,但也新增 degraded: true 診斷及每份協調器一次 warning。此時 diagnostics.policy='strict' 表示未啟用新的 HTTP 相容政策;實際策略由 coordination='single-context' 表示,不可只看 policy 判定互斥保證。 /utils 新增安全亂數 randomUUID()(原生 UUID → getRandomValues,不使用 Math.random)、 UuidSource、BrowserCapabilityError 與 BrowserCapability。 相容模式保留 family fence、取消/逾時後 drain、 localStorage 非機密資料與原有認證/Cookie 規則;不提供 HTTP 的 MSW Service Worker 替代。

  • 新增 opt-in Mock Access Session 與跨分頁 Cookie 協調能力(19.0.0-alpha.137)。 mockAuthPlugin({ accessSessions: { enabled: true } }) 發行 opaque Access、查核 principal、固定 Session 截止、 requestId 技術去重、known-family fence 與 current/subject revoke;既有預設 factory/路徑保持 opt-out。 /utils 公開 createSessionCoordinator、createMockAccessSessionClient 及中性 DTO 型別; HTTP client 的 sessionCoordinator/sessionLease、原子 auth.getSession 與 AuthTokens.sessionId 接入同一鎖。 UI 取消/逾時後持鎖至 handler、產品請求與 Native response body settle;遲到回應與 401 重放核對原 family。 同 family 的跨頁 reconcile 保留 generation,不誤拒絕其他分頁進行中的 GET/POST;expectation 改變才換代。 Native replacement/logout 以秘密 selector 證明持有 family,refresh 仍要求目前 verifier。 採用時須與 Server 4.0.0-alpha.39 配對、同 Cookie scope 全部 context 升級、具備 storage/Web Locks, 非 issue mutation 明示原 Session ID,leased request 使用 authMode none/session-no-refresh。 Native client 僅供 mock,正式 Server 使用 JWT profile;產品啟用與 artifact 發布另行執行。

  • StatusBadge(19.0.0-alpha.135)提供 24px 高、13px 字、8px 圓角的狀態構圖及可選膠囊外型。 StatusBadgeIndicator 支援 10px 圖形槽與 8px 實心/空心圓點、6px 文字間距與選用 CSS 顏色; 指示為裝飾性,狀態意義由可見文字說明,應用自行映射圖形/配色,不綁定業務 enum。 既有 Badge/Chip 預設尺寸及膠囊外型不變。

  • ReportArtifact.diagnostics/ReportLayoutDiagnostic(19.0.0-alpha.134)支援固定 PDF 的結構化排版 warnings;ready 仍可預覽、下載及列印,ReportViewer 持續顯示問題的原始頁碼、元素與區域。 含 warnings 的列印成功後保留選頁對話框;呼叫端的 onPrintComplete 須同樣保留警示介面。 createReportPrintAdapter 在直接列印前透過可取消的警示對話框顯示全部問題,使用者選擇 Print 才繼續,支援 AbortSignal;無 diagnostics 的既有流程保持原行為。

  • DataTableColumn 與 VirtualTableColumn 的 meta.skipRowSelection 可讓整個儲存格 略過列選取,支援自訂控制及 sticky 欄;VirtualList 自訂控制可用 data-skip-row-selection="true" 同時略過點擊與鍵盤選取。

  • PdfViewer 與 MediaViewer 新增 onDownloadStart、onDownloadSuccess、onDownloadError, 沿用 FileDownloadLink 的下載流程契約;ReportViewer 可透過 viewerProps 傳入。 內部 API 先完整取得 Blob,MediaViewer 下載全部另完成 ZIP 打包,再回報成功觸發瀏覽器下載; 外部/預簽 URL 無法確認後續傳輸,success 不代表檔案已儲存至磁碟。 列印退回下載仍使用列印回呼,不觸發這組事件。

  • MediaViewer、PdfViewer 與 ReportViewer 新增 controlSize(預設 auto), 依實際檢視區寬高選擇 xs/sm/md(24/32/40px),可手動指定;全螢幕與 宿主縮放時重新量測。全域動作、圖示與導航同步調整,MediaInput 嵌入與縮圖局部 操作維持 xs;PDF 工具列空間不足時將次要動作收進可鍵盤操作的選單。

  • Dialog 新增 draggable(預設 false),可拖曳標題列或以把手的方向鍵移動, Shift + 方向鍵加大步幅,Home/點擊把手恢復置中。視窗四邊保留 16px, 視窗與內容尺寸改變時修正位置,重新開啟恢復置中;保留模態、焦點與關閉行為。

  • 新增 SignatureInput 與 React Hook Form 表單版本,支援滑鼠/手指/觸控筆、清除、 受控向量筆畫還原、容器縮放與高解析度呈現、唯讀/停用、標籤/錯誤及鍵盤清除。 公開 SignatureValue、SignatureData、SignatureStroke、SignaturePoint、元件 props、 外觀/密度及 SignatureExportOptions 型別;提供 isSignatureData、isSignatureEmpty、 exportSignaturePng(Blob)、exportSignatureFile(File)及 useSignatureInput。 框架驗證器新增 schema.signature().required(),拒絕沒有可呈現筆跡的簽名。 受控值須保持物件參照穩定;新參照會取消書寫中的草稿。資料上限為邏輯寬/高各 4096、 1024 筆畫、共 20,000 點;PNG 單邊上限 4096px、總像素 4,194,304,pixelRatio <= 8, 省略倍率時優先 2 倍並等比例降低至預算內;明確指定倍率超限時在配置 canvas 前拒絕。 輸入達上限則透過常駐 live region 與欄位描述提示清除重簽。 替代簽收、檔案保存、時間與提交後鎖定由應用及後端承接。

  • MediaInput 新增 capture?: 'user' | 'environment',傳至內部原生 file input, Forms 版同步繼承。預設不設定,維持既有選檔行為;此屬性為鏡頭偏好提示, 實際相機及相簿選擇能力取決於瀏覽器與裝置。

  • AuthImage 與 PdfViewer 新增 contentFallback,沿用共用 MediaItemFallback 契約, 在載入前呈現已有但暫不可讀的圖片/文件;替代期間不讀取原始來源,切換時取消進行中的 載入並釋放資源。AuthImage 與既有載入失敗的 fallback 分開;PdfViewer 不提供下載、 列印與文件操作,恢復可讀後重新初始化閱讀狀態,可用受控頁碼保留位置。

  • FileInput 新增逐項 getItemFallback、getItemKey,從 components 入口公開 FileInputSourceContext、FileInputSource 與 FileInputValue, 保留檔名、原始值、穩定識別與允許的移除/替換操作,編輯與唯讀共用狀態語意; 替代項目不提供預覽/下載,狀態改變立即關閉該項預覽。下載全部與 onDownloadAll 只包含可存取且未被替代的來源,沒有可讀項目時隱藏批次下載。Forms 版同步繼承新 props。

  • MediaInput 與 MediaViewer 新增逐項 getItemFallback,公開共用 MediaItemFallback 與 MediaViewerItemContext。已有媒體暫不可讀時,以自訂圖示/文字與 ariaLabel 取代 single/thumbnail/carousel 畫格,保留來源識別、位置、尺寸與排序;不載入原始媒體、 不提供該項預覽、下載或格式操作,下載全部只包含可讀媒體。狀態更新會取消進行中的 檢視載入並關閉該項預覽,恢復可讀後回復正常呈現。MediaInput 保留既有權限設定允許的 移除、替換與排序,action context 新增 isMediaAvailable;自訂縮圖可使用 helpers.fallbackContent 與 helpers.isMediaAvailable。此能力與零項目的 emptyContent 分開。 MediaViewer.src 派生的項目改與 MediaInput 共用穩定來源 key(優先 fileId,重複來源補 suffix), 不再以時間戳產生新識別,讓同一項目的狀態更新保留縮圖節點。

  • MediaViewer 新增 container-based columns,與 MediaInput 共用縮圖網格型別、預設值與 Tailwind class 產生器;同步公開 MediaThumbnailBreakpoint、MediaThumbnailColumnCount、 MediaThumbnailColumns 與 DEFAULT_MEDIA_THUMBNAIL_COLUMNS。每級支援 1~12 欄,保留 MediaInput 既有欄數型別與 DEFAULT_MEDIA_INPUT_COLUMNS 別名;內建方向鍵導航會隨 執行期欄數設定重新量測,自訂 renderThumbnailContainer 仍由呼叫端負責排版。

  • 公開共用型別 CustomColor = { base: string; content: string } 與 Color = ComponentColor | CustomColor, 由 @appfuse/appfuse-web/components 匯出。Badge.color/Chip.color 接受 Color, 自訂色以 DaisyUI 的 --badge-color/--badge-fg 套用,沿用既有 solid/soft/outline 樣式, 支援 CSS 顏色值與 CSS 變數,並保留呼叫端 style 覆寫。Storybook 新增可編輯 base/content 的 Custom Colors 展示,Playground 的 color 也可選 custom。

  • Chip.variant 支援 solid/soft/outline,預設為 soft;公開共用型別 BadgeVariant, 與 Badge.variant 使用同一組詞彙。outline 保留主題邊框與透明底色,三種填色樣式維持相同尺寸與刪除操作。 TagInput、Select、FileInput 的欄位內 chip 仍統一使用 soft。

  • Chip.size 與公開型別 ChipSize:sm(13px 字/24px 高)、md(15px 字/32px 高,預設)、 inherit(依父字級縮放;父字級為 16px 時為 15px 字/32px 高)。size 同時控制字級、內距與圖示。

  • ReportViewer 的 server 選頁列印(REP-08):createReportPrintAdapter({ httpClient, subsetUrl }) 建立 ReportViewerAdapter,「全部」直接列印 artifact.source,其他選頁以 formatReportPages(selection) 的正規形式 (例如 1-3,5,與 appfuse-server ReportPages 的 wire 語法相同)呼叫 subsetUrl 取得 subset PDF,在頁面內的 隱藏 iframe 載入後只呼叫一次 print(),不開新視窗;取消或卸載時中止請求且不列印,沒有內建 PDF 檢視器時改為下載。 失敗以 ReportPrintError 分類:410 → report.expired、404 → report.unavailable、400 或選頁超過 REPORT_PAGES_MAX_LENGTH(4096)→ report.invalid-selection、列印 iframe 無法開啟 → report.print-unavailable, 其他錯誤原樣拋出。另公開 ReportPrintAdapterOptions、ReportPrintErrorCode。選頁對話框補充頁碼說明。

  • PdfViewer 新增 memoryLimits 與公開型別 PdfMemoryLimits(renderedPages 預設 6、retainedPages 預設 16、renderedThumbnails 預設 24):限制 continuous 模式同時掛載的頁、閒置時仍保留渲染資源的頁 與縮圖側邊欄同時掛載的縮圖數。PDF.js 內部保留的 page proxy、worker 已下載的位元組與 worker 快取不在 此限制內。載入失敗畫面新增「Retry」與「Download file」,載入中顯示已讀取的比例。

  • Chip 的 icon 另接受語意圖示物件 ChipSemanticIcon({ component, label, tooltip? },由 @appfuse/appfuse-web/components 匯出型別)。傳元件時維持純裝飾(aria-hidden、不可聚焦),既有 icon={Tag} 與 TagInput、Select、FileInput 的欄位內 chip 不受影響。傳物件時圖示以 role="img"

    • aria-label 呈現,label 為靜態字串、由框架 t() 翻譯;另提供 tooltip(呼叫端已翻譯的內容)時, 圖示可用 Tab 聚焦(位於刪除鈕之前),hover/focus 顯示說明、Esc 關閉,點擊不觸發 chip 的 onClick 或 onDelete。disabled 時保留 role="img" 與名稱,但不可聚焦、不顯示 Tooltip。
  • DatePicker 新增 type="year-month" 與公開 YearMonthDatePickerProps(元件與 form 版皆有):以 ISO YYYY-MM 字串交換值,對齊後端 java.time.YearMonth(月結期別、到期月份)。欄位為 YYYY/MM 分段遮罩, 選單為年份導航+ 12 個月,可用 disabledMonth 停用月份(只約束選單);不符 YYYY-MM 的值以 inline error 呈現。日層與時區 props 在此模式型別上不可用;disabledMonth 僅限此模式。

  • 月曆的年/月快速選單按鈕加上 aria-pressed,標示目前選定的年份與月份。

  • prompt.success/info/error 新增 onAction(原始操作鍵、一次性回呼);公開 PromptOptions、PromptNotificationOptions、PromptConfirmationOptions。warn/confirm/warning 支援 AbortSignal 取消等待與移除呈現中的訊息。

  • ButtonLink 新增 mode="protocol" 與公開 ProtocolButtonLinkProps,供 tel:/mailto: 等原生協定連結使用;預設不附外部標記、不輸出 target。既有 external 網頁模式維持 _blank 預設。

  • MediaInput 縮圖左上平時顯示標籤,hover/鍵盤聚焦時暫時隱藏,由自訂 action 或拖曳把手 原位取代;More 開啟期間維持隱藏,操作結束後恢復。標籤不再強制 action 收入 More。 拖曳、鍵盤排序與既有操作互斥規則不變。

  • MediaViewer.thumbnailBadge/MediaInput.itemBadge 提供常駐文字標記,框架自動翻譯並統一 半透明語意色 chip、圓角、截斷與操作避讓;編輯、唯讀與直接 Viewer 共用外觀。 undefined/空字串不渲染;既有自訂 badge renderer 優先(含空值),樣式仍由呼叫端負責。

Data Display / Chip (lib/components/data-display/chip/)

  • 新增 Chip 與公開型別 ChipProps、ChipSize、ChipIcon:建立在 Badge 之上的標籤,預設採 soft 填色, 可加前置 icon 表示標籤性質,提供 onDelete 時顯示刪除鈕,用於「這筆紀錄關聯了哪些標籤」這類可移除項目。 size 有 sm(13px 字/24px 高)、md(15px 字/32px 高,預設)與 inherit(以 em 隨父字級縮放)。 刪除鈕可用 Tab 聚焦,點擊不冒泡到 chip 的 onClick;無障礙名稱預設為「Remove {內容}」,可用 deleteLabel 覆寫。disabled 會淡化 chip 並隱藏刪除鈕;內容受容器寬度限制時以省略號截斷。 純狀態標示仍用 Badge。

Data Display / MediaViewer (lib/components/data-display/media-viewer/)

  • 新增 renderThumbnailBadge 與 getThumbnailAttributes:縮圖可顯示左上常駐非互動標記,並在 option 根節點帶業務 data-;公開 MediaThumbnailAttributes。自訂 renderer 由 helpers.badge 與 optionProps 接收共用標記及屬性。標記不受 hover/操作互斥影響,小尺寸仍保留;上方操作避讓。 資料依目前 item/index 更新,執行期過濾非 data 屬性、非字串值與保留的 data-media-。

  • MediaViewer 新增 emptyMessage 與 emptyContent,讓應用層覆寫「沒有媒體」時的空狀態: 前者只換文案、維持框架構圖,後者完全取代預設內容(可換圖示、放行動入口)。未提供時行為不變, 仍走內建的 ImageEmptyState。同一畫面並存多個語境不同的空 viewer(如「尚無作品照」與 「尚無簽收照」)時,不必再於外層自行二分支、手動追平 viewer 的尺寸與底色。 提供 emptyContent 且沒有項目時,viewport 的 role 由 img 改為 group, 避免自訂內容中的按鈕、連結被 role="img" 吞掉語意;emptyContent 由 viewport 置中、 寬高依內容而定,要填滿 viewport 需自行加上 w-full h-full。 只作用於「完全沒有項目」的空狀態;單一項目缺少來源時仍走既有的中性佔位。

Data Input / MediaInput (lib/components/data-input/media-input/)

  • 新增宣告式 itemActions 與公開 MediaInputItemAction 型別,沿用 MediaInputItemActionContext。 框架統一內建控制鈕樣式、圖示尺寸、label 翻譯、hidden/disabled 與 compact/minimal 收合; single/carousel 同樣支援。renderItemActions 保留為自訂出口,並存時依序合併計算可見操作。 Storybook 新增「設為封面」縮圖範例,管理範例移除應用端按鈕樣式拼裝。

  • 新增 renderItemBadge(沿用 MediaInputSourceContext)與 getItemAttributes,映射到 MediaViewer 的縮圖標記/屬性出口;File 與 FileDescriptor 混合來源、排序、刪除及唯讀模式均使用相同路徑, 不需 portal 或 MutationObserver 注入 DOM。標記與詳情 viewer 共用外觀及定位,actions/拖曳提示避讓。

  • MediaInput 新增 emptyMessage 與 emptyContent,只在 readOnly 生效。可輸入的欄位 「沒有內容」是預設狀態、不是空狀態——該位置屬於上傳入口(dropzone 或新增位置),用空狀態 取代它等於拿掉唯一的輸入入口;唯讀欄位沒有輸入入口,此時才與 MediaViewer 的空狀態同義。 同時修正 readOnly 且沒有任何媒體時只剩一個空白外框(既有的上傳區在唯讀下回傳 null、 又沒有替代內容):現在一律顯示空狀態,且不再渲染 showAddTile/capacitySlots 的保留位置。

  • MediaInput 新增 container-based columns、thumbnailObjectFit、非同步 beforeRemove、 renderItemActions、itemActionsVisibility、getItemKey 與具接受/回復語意的 onOrderChange; 排序支援 Alt + 方向鍵並以 live region 公告成功/回復結果,非同步持久化失敗會回復原順序。 桌面 hover/focus actions 以單一縮圖為觸發範圍,不會因整個 MediaInput 取得 hover/focus 而全部顯示。

  • FileDescriptor.fileId 現在優先作為 React/DnD 穩定 identity;同步公開 MediaInputSource、 MediaInputColumns、action/ordering context 與相關型別,以及 DEFAULT_MEDIA_INPUT_COLUMNS; 增量加入相同來源時會避開既有 item key,維持 React、刪除及 DnD identity 唯一。

  • MediaInput 新增 collapseToSingleBelow、showAddTile/renderAddTile、capacitySlots、 removable 與 thumbnailContainerHeight,支援不收合的窄容器網格、固定容量位置、網格內新增卡片, 以及「可替換但不可刪除」的主圖組合;只設定 capacitySlots 且尚無媒體時仍保留上傳 dropzone。 capacitySlots 現在代表網格總位置數:新增卡片直接占用媒體後的第一個空位,其餘才補空 slot; 達到固定容量後新增卡片消失,不再額外產生第六格。 renderAddTile 只渲染視覺內容,可點擊的 role="option" 根節點由框架建立。MediaViewer 同步公開 可停用的自動收合門檻與 bottomActionsSlot;關閉收合不影響獨立的 400px 小螢幕控制項門檻。 已有媒體且沒有網格新增卡片時,MediaInput 的上傳按鈕改列於右下角下載按鈕左側。

Overlays / Popover (lib/components/overlays/popover/)

  • Popover 新增 closeOnSelect:點擊內容中的有效 action 後關閉並把焦點返回 trigger;容器空白及 disabled/aria-disabled action 不會誤關閉。適合以任意 action node 組成的 compact 操作面板。

Navigation (lib/components/navigation/tabs/)

  • TabList distribution="content" | "equal" — 預設 content 維持既有依內容寬度靠起點排列; equal 讓直接子層 Tab 等寬填滿容器,適合數量固定且重要性相同的頁籤。同步公開 TabListDistribution 與既有但先前未由 Tabs entry point 匯出的 TabListOverflow 型別。 equal 與保留內容寬度的 overflow="scroll" 語意互斥:型別會拒絕此組合;動態或 JavaScript 呼叫則記錄錯誤並降級為 overflow="wrap",避免在 render 期間拋錯而卸載 React tree。

Routing (@appfuse/appfuse-web/routing)

  • 新增 routing entry point,提供深入頁面(Detail/Editor/Viewer)的情境式返回能力, 取代頁面自行寫死返回 URL 或以 query param 傳遞來源的作法(Web ADR-009)。
  • useReturnAction() — 回傳 goBack() 與解析結果(kind/to/label)。解析順序為 獨立視窗關閉 → 應用內上一頁(navigate(-1))→ 最近的 <ReturnBoundary> 落點 → fallback (預設 /);邊界與 fallback 一律 replace,返回不在歷史堆疊留下深入頁面。
  • <ReturnBoundary path label> 與 useReturnBoundary() — 由 collection/applet root 宣告返回落點。 以 Context 而非 route handle 解析,因此同時涵蓋 data router route objects 與 applet 內的 宣告式 <Routes>;巢狀邊界取最近者。
  • useUnsavedChangesGuard() — 未儲存內容的統一離開攔截,同時涵蓋應用內導航(React Router blocker)與整頁離開(beforeunload);需 data router。
  • hasInAppHistory() — 以 window.history.state.idx 判定是否存在應用內上一頁,涵蓋應用內進入、 重新整理、直接連結與新分頁四種進入來源。
  • openDetachedWindow()/isDetachedWindow()/closeDetachedWindow()/DETACHED_WINDOW_NAME — 獨立視窗的 opt-in 標記;只有經 openDetachedWindow() 開啟的視窗,返回才走關閉分支。

Vite (@appfuse/appfuse-web/vite)

  • mockAuthPlugin() — 由 Vite dev-server process 持有 Refresh Session,使用原生 HttpOnly Cookie 並支援 strict credential rotation、replay family revoke、expiration 與 remember-me,使 hard reload/direct deep link 不再因頁面 JavaScript context 重建而遺失 Session。預設 Cookie 名為 APP_SERVER_REFRESH,亦匯出 DEFAULT_MOCK_REFRESH_COOKIE_NAME 與設定型別。

HTTP (lib/utils/http/)

  • createHttpClient 的 refresh single-flight 現在再以同源 Web Lock appfuse:refresh-session 包住, 跨分頁 refresh 會等待上一個分頁完成 Set-Cookie 後才送出,支援 Server strict rotation 且不把 合法多分頁競態誤判為 replay。等待鎖預設最多 60 秒(refreshLockTimeoutMs),取得鎖後 refresh callback 另有完整 15 秒期限(refreshTimeoutMs);callback 逾時保證釋放鎖,只發布 timeout availability 訊號,不會把內部 refresh 列為待使用者對帳的 business mutation。Browser 缺少 Web Locks 時會記一次 warning,避免非 secure context 靜默降級。
  • createHttpClient({ withCredentials }) — 支援跨 origin 攜帶 HttpOnly Refresh Session Cookie, 讓 Browser 不需把 refresh credential 暴露給 JavaScript。
  • 新增 classifyRefreshFailure()、requiresReauthenticationAfterRefresh() 與 REFRESH_SESSION_INVALID_* exports,集中判斷 stable refresh-session-invalid;無 stable code 的 legacy Refresh 401 會記 warning 後要求重新認證,transport/timeout/5xx 則保留 Session。
  • createHttpClient({ systemEvents }) — 將 Refresh Session 明確失效、網路/timeout/5xx 與 mutation 傳輸結果不確定分類成 typed system events,讓 apiClient 與 App Shell 呈現解耦。 主動 refresh 回 null 時也會在原請求送出前直接停止。
  • HttpRequestConfig.authMode('session' | 'session-no-refresh' | 'none')— 以 request-level 契約 決定請求是否參與目前 session 的 Bearer 附加、主動 refresh 與 401 refresh/retry;預設 session, login、refresh、Email OTP 等建立新 session 的公開認證請求指定 none;logout 等必須附加目前 Bearer、但不得延長或恢復 Session 的操作指定 session-no-refresh。

Utils (lib/utils/time/)

  • isValidTimeZone(timeZone) — 檢查目前 JavaScript runtime 是否支援指定 IANA zone,供設定資料在進入 Intl.DateTimeFormat 前走可呈現的驗證路徑。
  • 新增公開時區換算 API:browserTimeZone()、instantToZonedDate()、 instantToZonedWallClock()、zonedWallClockToInstant()、timeZoneOffsetMinutes()、 todayInTimeZone()、timeZoneLocation(),以及 TimeZoneDisambiguation 型別。這些 API 可從 @appfuse/appfuse-web/utils 直接匯入;wallClockToInstant() 是既有 API 的命名空間搬遷,見 Removed。

Utils (lib/utils/msw/)

  • reactivateMswInterception()(@appfuse/appfuse-web/utils)— 對控制本頁的 Service Worker 補送一則 MOCK_ACTIVATE,把本 client 重新加回 MSW worker 的 activeClientIds,使攔截即刻恢復、不需重整頁面。 回傳 worker 是否於逾時內回覆 MOCKING_ENABLED(false = 無法自癒)。純瀏覽器 API,不依賴 msw 套件。
  • createMswHealthProbe() + MSW_HEALTH_PATH / MSW_HEALTH_HEADER / MSW_HEALTH_MARKER (@appfuse/appfuse-web/utils)— 建立攔截健康探測函式:實際打一個 mock-only 端點,逐項驗證 HTTP status、content-type、marker header 與 payload marker,健康回 null、失效回診斷資訊 (MswHealthIssue)。應用側以三行註冊對應 handler(常數由框架提供,兩側共用同一份定義)。
  • createMswHealthCoordinator()(@appfuse/appfuse-web/utils)— 建立應用內單一 MSW 健康狀態來源: 合併並發探測(single-flight)、短暫快取健康與失效結果、失效時自癒後複探、狀態去重發布。 同一實例可交給 createHttpClient({ mswHealth }) 與 <MswHealthGuard health={...} />,使請求路徑與 app shell 不再各自維護探測、自癒與狀態。

Feedback (lib/components/feedback/msw-health-guard/)

  • MswHealthGuard 改以必填 health: MswHealthCoordinator 消費框架協調器。守衛在初次掛載、分頁重新 可見與取得焦點時強制探測,也會訂閱 createHttpClient 在請求路徑發布的失效狀態;自癒失敗才顯示 提示條。runtime enabled 判定由協調器統一持有,避免 UI 與 HTTP client 旗標分歧。

Feedback (lib/components/feedback/server-availability-guard/)

  • ServerAvailabilityGuard / ServerAvailabilityPanel — Server 確認不可用後以不可關閉的全畫面 Dialog 暫停操作,同時保留原 route tree 與表單狀態;恢復後仍等待使用者確認,逐筆顯示結果不確定 mutation 的 method/path,但不保存或重放 request body。MSW 連續自癒失敗達門檻後,會先警告未儲存 內容可能遺失,再以二次確認提供重載逃生口(reloadAfterAttempts / onReload)。

Feedback primitives (@appfuse/appfuse-web/components)

  • 新增 Progress 與 ProgressProps:以原生 <progress>/progressbar 語意表達工作完成進度;提供 value 時為 determinate(max 預設 100),省略時為不輸出 value/max 的 indeterminate。
  • 新增 Meter 與 MeterProps:以原生 <meter> 語意表達有界量測值,支援 min/max/必填 value、會翻譯的 accessible label/value text,以及與 Progress 一致的 DaisyUI color。元件不內建 quota 等領域狀態、觀測時間或 color mapping;缺值必須由 consumer 顯示 no-data representation, 不得以 0 代替。

Feedback (lib/components/feedback/prompt-dialog/)

  • PromptDialog 新增 density?: ButtonDensity('tight' | 'compact' | 'comfortable',預設 'compact'),套用至全部動作鍵;先前固定為 compact。右上角關閉鍵不受影響。 MessageDialog 同名 prop 透傳,讓 prompt.*() 產生的阻塞式對話框可統一調整動作鍵密度。

Messaging (lib/messaging/)

  • usePromptQueue({ severities, currentPath }) — 自建 messaging 容器的入口,回傳 { messages, dismiss }。訂閱、severity 過濾、thread 隔離、去重更新與移除整包提供, MessageDialog / MessageToast 本身即建構於此。要換佇列策略(一次顯示幾則)、換過濾 規則或換呈現,用它加上 Dialog / Toast 自行組合即可。

  • translatePromptMessage() / resolvePromptAction() / resolvePromptDismissal() / DEFAULT_PROMPT_TITLES — 佇列訊息與呈現層之間的轉譯與回接。佇列存的是翻譯鍵與 原始動作字串,呈現層要譯文,而 prompt.confirm() 的 promise 必須 resolve 回原始字串; 這條往返先前只存在於 MessageDialog 內部,自建容器得逆向工程,做錯的代價是 await prompt.confirm() 永遠不 resolve,或 resolve 出呼叫端認不得的譯文。

  • createSystemEventChannel() / useSystemEvent() — typed、可回放且保留各事件最新快照的全域系統 事件通道,讓 apiClient 與 Redux/Modal/Guard 解耦。

  • createServerAvailabilityCoordinator() — 訂閱連線可疑事件,以 single-flight health probe、指數 退避與 jitter 管理 available → suspected → unavailable → recovered,直到使用者確認才回復可用; 並提供 resolveUnknownRequests() 讓應用層不受 availability state 限制地清除已完成對帳的 mutation。

Actions (lib/components/actions/)

  • ButtonLink、ButtonLinkProps、InternalButtonLinkProps 與 ExternalButtonLinkProps (@appfuse/appfuse-web/components)— 以 mode + to/href 提供 internal/external discriminated API。internal mode 省略 mode 即維持完整 React Router Link 行為;external mode 輸出原生 <a href>,預設新分頁並強制補齊 noopener noreferrer,且要求可見、會納入 accessible name 的 externalLabel。兩種模式都保留 modified click、中鍵、右鍵選單與輔助科技 link 語意, 同時與 Button 共用 variant、color、density、shape、hover、focus-visible、active 的 class composition。新增 disabled representation:仍要求 to/href,但不向 DOM 輸出目的地、 internal router props 或 external target/rel,並以 role="link"、aria-disabled="true"、 tabIndex={-1} 與 Button disabled classes 呈現;click/中鍵不會導航或觸發 consumer handler。 title / aria-label / externalLabel 延續框架自動翻譯;仍不提供 loading、confirmation 或 warning。URL 信任與 allowlist 仍由消費端/API 契約負責。
  • FileDownloadLink(@appfuse/appfuse-web/components)— 提供受保護檔案下載用的 link 樣式 action。 內部 API URL 透過 HttpClientProvider 注入的 client 取得 Blob,自動沿用應用的 baseURL、Bearer token 與 401 refresh;外部/presigned/blob/data URL 則交由瀏覽器原生下載,不向第三方附加認證資訊。 支援檔名覆寫、載入/禁用狀態、DaisyUI 語義色、成功/失敗 callbacks,以及預設的全域錯誤通知。
    • 新增 request 模式(與既有 source 二擇一),支援 GET params、POST body、headers、AbortSignal, 並依序以明確 filename、回應 Content-Disposition、fallbackFilename、URL 尾段決定檔名。
    • 公開 FileDownloader、useFileDownloader()、FileDownloadTarget、FileDownloadRequest、 FileDownloadError 與進度型別等下載引擎 API,讓需先驗證/儲存再下載的業務操作與 FileDownloadLink、FileInput、MediaInput 共用同一實作。

Data Input (lib/components/data-input/)

  • Input 新增 revealable prop(@appfuse/appfuse-web/components 與 /form 兩版)——密碼欄位的 明碼顯示切換鈕(眼睛),補齊 Input 既有的 type-conditional 右側控制項家族(clearable 的清除鈕、 date 的日曆鈕、number 的增減鈕),此前各應用需自行以 rightAddon 重造,含 aria-label、 tabIndex={-1} 與 addon 內距計算等細節,已在 fleet 內出現無障礙標註不一致的分歧。

    • 僅 type="password" 生效,其餘 type 忽略;readOnly / disabled 時不顯示。
    • 預設 false(opt-in):明碼顯示在高安全情境(如二次驗證 modal)可能是刻意不提供的能力, 故不隨 type="password" 自動開啟,升級不改變既有畫面外觀。
    • 切換狀態由組件內部持有,只改 DOM 的 type attribute(password ⇄ text),不動 props.type,故表單層的資料轉換與驗證完全不受影響——此前應用層在 form 層 Input 上切換 type,該值會流進 useInput 的轉換管線。
    • 與 clearable 及自訂 rightAddon 並存(不像日曆/數字按鈕會被自訂 addon 取代), 因為 revealable 是顯式 opt-in。
    • 新增 nls terms Show password / Hide password。
  • 日期時間 picker 新增公開辨識式 props 型別:DatePickerInstantTime、 InstantDatePickerProps、LocalDatePickerProps、InstantDateTimePickerProps、 LocalDateTimePickerProps,可從 @appfuse/appfuse-web/components 匯入。

  • OtpInput(@appfuse/appfuse-web/components 與 /form 兩版)——分格驗證碼輸入 (OTP / PIN / 交易代碼)。視覺為 length 個分格,底層是單一 <input> 透明覆蓋其上, 分格僅為 aria-hidden 的呈現層。

    • 為何不是 length 個輸入框:分成多格必須自行搬移焦點,且會讓「一次貼 6 碼」只落進第一格、 破壞作業系統的 autocomplete="one-time-code" 自動填入、並讓螢幕閱讀器聽到 6 個無標籤欄位。 單一輸入框讓貼上、自動填入、IME 與螢幕閱讀器全部沿用原生行為。
    • 貼上萃取:貼上含前後文的整段文字(如「您的驗證碼為 482731,10 分鐘內有效」)只取 獨立的 length 位代碼,不會把其他數字串接進來;帶分隔符的 482-731、482 731 亦可還原。 萃取邏輯另匯出為 extractOtp / sanitizeOtp 供應用層重用。
    • onComplete 接縫:輸滿位數時觸發(含貼上一次填滿)。自動送出屬應用層決策,框架不內建 送出;需要「輸滿即驗證、畫面不放送出按鈕」時在此送出即可。
    • 字元集:type 為 numeric(預設,inputMode="numeric")或 alphanumeric(交易代碼類, 預設轉大寫,可用 uppercase 覆寫)。
    • 家族對齊:沿用 label / hideLabel / helperText / error / required / density / color / disabled / readOnly 與自動 data-testid。三處刻意偏離:variant 值為 filled / bordered(分格外觀)、標籤置於分格上方不做浮動動畫(分格無容納浮動標籤的內縮 空間)、無 placeholder(分格本身即佔位)。內層 class 的 prop 為 cellClassName。
    • form 版以 useOtpInput 綁定 react-hook-form(空值寫回 null,對齊框架表單慣例), 並透傳 onComplete。
  • MediaInput 新增下載能力(單檔 / 下載全部):hover 媒體區右下角出現下載鈕——gallery 檢視為「下載全部」(打包 zip)、單檔檢視為「下載當前檔」(原檔名)。受保護媒體(經 httpClient 帶 token 抓 blob 顯示)無法用瀏覽器右鍵另存,此鈕填補該缺口;readOnly 仍可下載(純讀取)。進度 / 成功 / 失敗狀態顯示於欄位下方 helperText 位置、訊息後自動消失(取代 helperText、消失後復現),失敗顯示後端 RFC 7807 訊息,任一檔失敗即中止(不產生殘缺 zip)。

    • 共用下載引擎:單檔 / 多檔 zip 下載、失敗即 throw(帶檔名 + 後端錯誤)、進度回報與內嵌狀態列抽為 data-input/file-download 共用模組,由 FileInput 與 MediaInput 共用(單一 JSZip 副本),不重複實作。FileInput 的下載改由此模組驅動(行為不變)。
  • 內層控制項 class——Input / Textarea / TagInput / Select 各新增一個選填 prop, 用於直接指定內層控制項的 class(@appfuse/appfuse-web/components 與 /form 兩版皆適用):

    元件新 prop套用對象
    InputinputClassName內層 <input>
    TextareatextareaClassName內層 <textarea>
    TagInputinputClassName內層輸入 <input>(tag chip 仍用 badgeClassName)
    SelectcontrolClassName觸發框(顯示已選值、點擊開啟選單的那層)
    • 解決什麼:既有的 className 套用對象是包住控制項 / label / notch 的外層 wrapper, 排版類 utility(text-center、tracking-*、字級)會經繼承散到浮動 label 與 notch legend 上—— label 被字距撐開、邊框缺口與 label 對不齊,而字級因控制項自帶 text-base 與 DaisyUI .input 的 font-size 而根本不生效(典型受害場景:OTP 驗證碼欄位想要置中大字距)。
    • 命名慣例:className = 外層 wrapper、{內層元素}ClassName = 內層控制項,沿用既有 badgeClassName 的元素具名慣例;四個 prop 的 JSDoc 互相交叉引用,避免再誤用。
    • 合併語意:以 cn()(tailwind-merge)接在元件內建 class 之後,故可直接覆寫內建的字級等 衝突 class,不需 ! important。
    • picker 家族自動涵蓋:DatePicker / TimePicker / DateTimePicker / InstantPicker 以 Input 為欄位外殼並 {...rest} 展開,故 inputClassName 直接生效,不需各自轉送。
    • 未變更者:Checkbox / Radio / Switch 的 className 本就直接套在控制項 <input> 上、 RichTextEditor 的 className 本就套在編輯器容器(label 為其兄弟節點,不受繼承影響), 皆無此問題;FileInput / MediaInput 無承載輸入文字的內層控制項,不適用。
    • 向後相容:純新增選填 prop;未傳時內層控制項的 class 組法與先前完全一致(不經 tailwind-merge), className 的既有語意與行為不變。同時補上四個元件 className 的 JSDoc,明示其作用於 wrapper。

Feedback (lib/components/feedback/)

  • variant(Dialog / MessageDialog,'solid' | 'soft',預設 'solid')+ DialogVariant 型別 — 調的是主要面的響度:solid 為實色標題列 + 實心 severity 主要鍵、soft 為柔色標題列 + severity 色框線主要鍵。次要鍵不受影響(恆中性框線)。

    • 與 actionHierarchy={false} 搭配是主要動機:扁平模式下全部動作鍵採主要樣式, solid 會得到兩顆以上的實心 severity 色鈕(error 即兩顆飽和紅),視覺過於強烈; soft 則為兩顆 severity 色框線鈕,扁平而不吵
    • 與同版稍早移除的 variant 的關係:原三值軸(solid/soft/outline)因「對稱 Toast 而生、 兩值零消費」被移除;此處是證據驅動地把 soft 單獨帶回(有具體病灶),outline 不回歸 (見 Removed)
    • 實作註:soft 主要鍵用框線而非淡底——標題列已是柔色,主要鍵若也用淡底會與之糊成一片。 因此不使用 Button 的淡底 soft 變體,而是明確選擇 outline。
  • actionHierarchy(Dialog / MessageDialog,預設 true)— 是否以樣式區分主次動作。 設 false 時全部動作鍵一律採主要樣式(severity 色實心),不作降階。

    • 給誰用:房子設計語言主張「對話框動作一律等重」的專案。此前這類專案只能整個 fork 掉對話框渲染——改用自訂的全域訊息渲染器只能解掉 prompt.*() 那條路徑, 直接使用 <Dialog> 的地方仍受影響
    • 只關樣式,不關語意:actions[0] 仍為主要(肯定選項)、MessageDialog 的關閉 / ESC 仍 resolve 最後一項、左右順序仍為「最後宣告的在左、actions[0] 在右」
    • 為何是開關而非 per-tier 的 variant / color 覆寫:需求是相對的(「次要與主要一致」), 而主要的顏色隨 severity 變動。覆寫是絕對值,在單一 <MessageDialog> 要服務所有 severity 的情況下表達不了這層關係(寫死 error 對 warning 訊息即為錯)。開關讓次要走與主要 同一條分支,跨所有 severity 皆正確。日後若出現「次要要用 ghost」這類絕對需求, 再加 per-tier 覆寫即可,兩者可疊加
  • AlertVariant / Alert 的 variant prop('solid' | 'soft' | 'outline',預設 'solid')— 對應 daisyUI alert style modifier,與 ToastVariant 對齊(Alert 與 Toast 是同一個 daisyUI alert primitive 的兩種投遞方式,樣式軸不應分歧)。

    • soft 對就地提示尤其有用:混在表單 / drawer 內容中時,整片實色常過重
  • Alert(@appfuse/appfuse-web/components)— 就地提示。與 Dialog(阻塞)、Toast(浮動) 共用同一組 Severity,是該語彙的第三種投遞方式,差別在位置與生命:Alert 留在版面內、 貼著出錯的區域、條件成立期間持續顯示。

    • 選型判斷句:使用者需要邊看著提示邊修正(表單驗證摘要、區塊載入失敗、狀態提醒) → Alert;全域性的一次性事件 → prompt.*()。後者的兩個渲染器都會離開版面—— prompt.error() / prompt.warning() 是阻塞模態(得先關掉才看得到要修的表單)、 prompt.success() / prompt.info() 是會自動消失的 toast(使用者還沒改完就不見了)
    • 補上 12-notifications.md 錯誤處理策略中「有表單 → Inline Alert」一列長期缺少的元件: 此前參考實作有 16 個檔案各自手貼 daisyUI alert alert-* class 與 inline SVG
    • 預設帶 severity 圖示(就地提示混在版面內容中,圖示是掃視時辨識嚴重程度的主要線索, 不像 Toast / Dialog 有獨立浮層或標題列可依靠);icon={<Custom />} 覆寫、icon={null} 關閉
    • onClose 給了才顯示關閉鈕,省略即為常駐提示(多數 inline 情境);可關閉與常駐兩者等高
    • title 給了則內容降為次要層級、圖示改頂端對齊;無 title 時圖示與單行文字置中對齊
    • 無障礙:severity="error" 為 role="alert" + aria-live="assertive"(即時打斷), 其餘為 role="status" + aria-live="polite"(不搶斷當前朗讀)
    • 配色與 Toast 共用同一份 daisyUI severity 對應(confirmation 沿用 info 樣式),不分歧

Overlays (lib/components/overlays/)

  • 模態家族的關閉鈕改為響應式觸控目標:手機 40px、sm 以上回到原本的 32px。 涵蓋 Modal、Sheet、Dialog 與 FileInput 的預覽對話框。density="tight" 的 32px 在滑鼠下夠用,但手指按不準,而覆蓋層的關閉鈕一律貼在 panel 右上角、誤觸代價是 把填到一半的表單或正在看的預覽關掉。只放大命中區、不放大圖示,視覺重量不變。 Alert / Toast 不在此列——那是行內通知,關閉鈕不在角落且 Toast 會自動消失, 放大只會擠壓訊息本文。

  • Modal.description?: ReactNode — 標題下方的補充說明 slot,渲染於內容區頂端、children 之上, 並自動以 aria-describedby 關聯到對話框。此關聯只有元件做得到——呼叫端自行在 children 裡 放一段說明文字,對輔助技術而言只是內容的一部分,讀不出「這是對話框的描述」。 未提供則整段不渲染,對既有用法零影響。

  • Tooltip.className — 提供 panel 樣式出口,可覆寫預設 max-w-xs、text-xs、padding 與換行方式, 支援「比一句話稍長、但仍不可互動」的提示;需要互動內容時仍使用 Popover。

  • Sheet size="wide" — 補上 lg(right 32rem/bottom 80dvh)與 full 之間的尺寸階梯;right drawer 使用 max(32rem, 66.667vw) 承載多欄明細/編輯工作區,bottom sheet 對應 90dvh。既有尺寸不變。

  • Popover(@appfuse/appfuse-web/components)— 錨定在 trigger 旁、承載任意可互動內容的非模態 role="dialog" 浮層。支援 click/hover(hover 同時支援鍵盤 focus)、四種 placement、Portal、 auto flip + edge padding、點外與 Escape 關閉、Escape 後焦點還原,以及 aria-expanded / aria-controls。 hover/focus 揭露不移動焦點;click panel 的 Tab 離開邊界會略過隱藏/inert 元素並回到 trigger 前後的邏輯順序,也涵蓋 contenteditable、iframe、media controls 與 summary。Escape 先交給 panel 內控制項消費,否則只關閉最上層 Popover,不連帶關閉巢狀 Popover 或外層 Modal/Sheet;若後開的 Modal/Sheet 疊在 Popover 上方,來自其 aria-modal="true" 容器的 Escape 會交由模態層處理。 React 19 callback ref cleanup 會完整轉發。 panel 的 className 可覆寫寬度與 padding;trigger 採 cloneElement 組合框架與 child handlers / ref, 不增加 DOM wrapper。Dropdown 保持純 menu 語意,不以 props 混成 dialog。

  • Sheet(@appfuse/appfuse-web/components)— 貼邊任務型模態外殼;同一個 Headless UI Dialog instance 支援 right drawer 與 bottom sheet,並以純 CSS 的 ResponsiveSheetPlacement(如 { base: 'bottom', md: 'right' })在 breakpoint 切換, 不需 matchMedia、不重建 children,也不會同時產生兩個 Dialog。

    • placement 僅提供 right | bottom,預設 right;刻意不擴充 left / top,避免在尚無消費 證據時放大公開設計語彙與 class 組合面。
    • size 為 sm | md | lg | wide | full(預設 md),語意軸隨 placement 改變:right 映射寬度, bottom 映射最大高度;完整 placement × size × breakpoint class 皆以字面 mapping 發布, 供下游 Tailwind 靜態掃描。
    • backdrop、Escape 與關閉鈕共用 onClose;closeDisabled 同時擋掉三條途徑, showCloseButton={false} 只隱藏按鈕、不影響其餘允許的關閉方式。
    • Headless UI 負責 Portal、focus trap / restoration、背景 inert 與 document scroll lock; body 獨立捲動使 header / footer 保持可見。
    • DOM anatomy 不列為公開契約;內建 data-testid 僅供框架測試,應用端樣式擴充使用 className,不得依賴內部節點名稱。
    • 方向性進出場動畫使用 Headless UI v2 transition + data-closed,並以 motion-reduce 移除動畫;顏色、圓角、陰影與 backdrop 全使用 DaisyUI / framework token。
    • 框架只提供模態責任、貼邊排版與動畫;應用端特定的啟用 breakpoint、篩選狀態與內容模型 不進入 Sheet API。
  • Modal(@appfuse/appfuse-web/components)— 承載自訂內容的模態外殼,children 與 footer 皆為 slot。補上 20-components.md 早已列出、實際卻不存在的元件。

    • 選型判斷句:需要顯示一則訊息、或要一個是/否的答案 → Dialog(severity 驅動、 content 是字串、actions 是資料陣列);需要放一個表單 → Modal
    • 為何 footer 是 slot 而非 actions: string[]:真實的任務型對話框,動作鍵需要圖示、 載入態與文字對調、綁表單狀態的 disabled、以及成對條件切換——皆非資料陣列可表達。 此前參考實作的四個對話框(取消訂單 / 上傳照片 / 草稿衝突 / 停用客戶)正是因此全部繞過 框架 Dialog、各自手拼 Headless UI
    • closeDisabled 一次擋掉三條關閉途徑(關閉鈕 + 點擊背景 + ESC)。此前手拼的四個 對話框只在關閉鈕與自家 handler 加守衛,處理中按 ESC 或點背景仍會關掉——屬既有缺陷, 一併修正
    • 內容過高時由內容區捲動,header / footer 保持可見(否則長表單會把 panel 撐出視窗、 footer 的送出鍵搆不到);footer 帶 border-t 與 header 對稱,捲動時邊界清楚
    • size(sm / md / lg / xl,預設 md)1:1 對應 Tailwind max-w-*
    • footer 的排列慣例為次要在左、主要在右,與同版 Dialog 的調整一致
    • 關閉鈕將 raw Close key 交給 Button 單次翻譯,修正 Modal 與 Button 各翻譯一次造成的 aria-label 雙重翻譯
    • 錯誤訊息屬呼叫端內容、放在 children(建議用 Alert),非 Modal 的 prop—— Modal 是外殼,不認識業務狀態

Data Display (lib/components/data-display/)

  • rowPosition —— 資料列人工位置(拖曳重排),DataTable / VirtualTable / VirtualList 三支共用同一套核心與語意。

    • 與排序是兩件事:sorting 是檢視狀態、position 是資料本身的屬性,故啟用時欄位排序 自動停用——不是額外規則,是必然結果。
    • 元件不計算位置值:只回報使用者做了什麼(把哪一列放到哪兩列之間),不需知道應用用哪個 欄位記錄位置。理由是元件算得出正確位置值的時機只有「資料全載且未篩選」——而那正是應用 自己算也毫不費力的情況;分頁邊界、篩選、浮點精度耗盡這些真正難算的情形元件都算不出來。 排序鍵方案(稀疏浮點、整數重排、lexorank、prevId 鏈結)因此完全由應用決定。
    • placement 有三種:{ type: 'between', beforeId, afterId }(拖曳,鄰居是 data 之內 的相鄰列,邊界以 null 表示該側無鄰居)、{ type: 'start' } / { type: 'end' } (命令式,語意是整個資料集的開頭 / 結尾,伺服器不需知道載入狀態即可執行)。
    • onReposition 可回傳 boolean / Promise<boolean> / void 決定元件是否保留樂觀順序; data 的 id 序列一變(伺服器順序真的改了)即以 data 為準。
    • onCommandsReady 交出 moveBy / moveToStart / moveToEnd,供應用接進自己的列動作—— 框架不內建上下移按鈕,UI 只有拖曳把手。
    • 表格由框架注入把手欄(釘最左、不進欄位面板);VirtualList 則把把手屬性交給 renderItem 的 state.positionHandleProps,由呼叫端自行安置(同該元件不塞勾選欄的決定)。
    • 適用數十至數百列。SortableContext 收全量 id,數千列以上拖曳會變鈍;大資料集請用命令式 API。
    • 新增 i18n key:Drag to reposition row(把手的 aria-label),下游需補字典。
    • dragPreview('displace' | 'lift',預設 'displace')—— 拖曳期間的視覺語言。 'displace' 是既有行為(其餘列即時位移讓出落點,verticalListSortingStrategy 假設等高列);'lift' 讓其餘列不動、改由框架在落點邊界畫一條 2px 的 accent 色插入指示線 (取 accent 而非 primary:主題的 primary 彩度低,細線讀起來像灰線),等高假設因此消失, 不等高列(VirtualList 明示支援)的落點才準確——這是它存在的技術理由,不是換個樣子。 兩種模式的把手、鍵盤操作、樂觀更新、pending 鎖定與失敗回退完全相同。刻意不走 DragOverlay: 那會同時撞上虛擬化(列捲出視窗即卸載)與 <table>(portal 裡的 <tr> 不合法)兩個硬點, 而被拖列在無 overlay 時本就跟著指標(dnd-kit 取 drag source 自己的 transform)。
    • 拖曳中的唯讀狀態外露:新增 useRowPositionDragState()(在三支元件的子樹內任意深度可用), 回傳 { activeRowId, sourceIndex, targetIndex };VirtualListItemState 另新增 isDragging 與 dropEdge('before' | 'after' | null)。狀態只讀不寫——拖曳期間 不呼叫 onReposition,順序仍只在放開時決定一次。新增 export:RowPositionDragPreview、 RowPositionDragState、RowDropEdge。
    • 觸控需長按 200ms 才拿得起來(滑鼠仍是移動 5px 即啟動)。原本只註冊 PointerSensor, 它連觸控一起吃,於是手指落在把手上捲動即可能誤觸拖曳;改為 MouseSensor + TouchSensor 兩組門檻。觸控靠時間而非 touch-action 與捲動區分意圖,故把手上的 touch-action 不設限——快速滑動照常捲動清單,停住 200ms 才是拖曳。門檻值由框架統一 (lib/components/shared/dnd-activation.ts),不開放逐處設定——「拿起一個東西要按多久」 是互動一致性、不是業務參數,且開放它等於把 dnd-kit 的 activationConstraint 形狀釘進 公開 API surface。
  • useVirtualScroll 新增 getItemKey(內部型別,非公開 prop)—— 虛擬化的量測快取改綁穩定 key 而非索引。目前僅 VirtualList 在啟用 rowPosition 時使用:只有它以 measureElement 動態量測(明示支援不等高列),項目一重排舊高度會錯位到別的項目上,直到下次量測才修正。 VirtualTable 的列高恆為 estimateSize,沒有快取可錯位,故不需要。

  • DataTable.height(string | number,選填)— 控制整個元件(表格捲動區 + 內建分頁器)的高度; height="100%" 會切到 flex/fill 版面,讓資料區使用 flex-1 min-h-0 捲動、分頁器固定留在底部, 下游不再需要 ResizeObserver 量測剩餘高度或猜測分頁器高度。未提供時維持既有自然高度;既有 maxHeight 保留為「只限制表格捲動區」的 API。height 與 maxHeight 同時提供會明確報錯,避免 衝突設定被靜默忽略。

  • rowSeparator(DataTable / VirtualTable)— 列區分方式的單一屬性,取代原本的 striped 布林(見 Removed)。三態 'zebra' | 'divider' | 'none',預設 'zebra'(等同原 striped 預設)

    • 'divider' 是新能力:明顯的列隔線(base-content/20)。DaisyUI 的 .table 原生雖有 tbody tr:not(:last-child) :is(td,th) 的 border-bottom,但只有 base-content/5,肉眼近乎無感, 且從無屬性可控
    • 實作只覆寫 border 顏色、不動 width / style——最後一列與 VirtualTable 的虛擬化佔位列本就 沒有 border-bottom(width 0),改色不會讓它們憑空冒出一條線
    • 'none' 以 border-b-0 歸零 tbody 隔線(thead 底線不受影響)
    • 與 bordered(外框)正交,可並用;sticky 欄的 hover 補色改由 rowSeparator 分流 ('zebra' → base-300,其餘 → base-200)
    • 一併匯出 TableRowSeparator 型別(data-table / virtual-table 兩個 entry 皆有)
  • PdfViewer continuous page render error — continuous mode 不再靜默留下空白頁;單頁 canvas render 失敗時顯示與 single mode 一致的可見錯誤狀態,其餘頁面仍可繼續操作。README 同步記錄 pdfjs-dist standard build 的瀏覽器 baseline 跟隨策略與目前實測組合。

  • toolbarActions + PdfToolbarAction(PdfViewer)— 宣告式的工具列動作,由 PdfViewer 以 內建工具按鈕的同一組樣式渲染(尺寸、色彩、hover、圖示大小),排在 toolbarSlot 之後、 內建按鈕之前。欄位為 { id?, icon, label, onClick, disabled?, pressed? };label 傳英文 i18n key,框架自動翻譯為 aria-label 與 tooltip。

    • 動機:toolbarSlot 只收 ReactNode,消費端要加一顆工具列按鈕就得目測抄內建樣式, 但 pdfViewerControlButtonVariants 並未對外公開——結果必然是尺寸、色彩與 hover 都對不齊。
    • 需要 icon 按鈕時一律用 toolbarActions;toolbarSlot 僅保留給無法表達成動作的客製內容。
  • ReportViewer(@appfuse/appfuse-web/components)— 報表使用情境的高階 PDF viewer。以 application-owned discriminated state 呈現產生中、可預覽、失敗與過期狀態;ready 時組合既有 PdfViewer,加入全部/目前頁/連續範圍/指定頁碼的 physical-page selection。公開 ReportArtifact、ReportPageSelection、ReportPrintRequest 與 ReportViewerAdapter,讓 Prototype 使用 mock adapter,server 整合版則取得已確認的 pageCount、在同一接縫建立 subset PDF 並呼叫瀏覽器列印;列印準備支援透過 AbortSignal 取消。元件不依賴 server ReportDocument、renderer 或 job Entity。

    • 列印按鈕經 PdfViewer 的 toolbarActions 渲染,與內建工具按鈕樣式一致;應用可用 ReportViewer 自身的 toolbarActions 加入更多按鈕(排在列印之前)。
    • controlsVisibility 未指定時採 'always'(非 PdfViewer 的 'auto'):列印是報表情境的 主要動作,藏在 hover 後面不利於發現;以 viewerProps.controlsVisibility 可覆蓋。
  • fitMode(PdfViewer,'width' | 'page' | 'none',預設 'width')— 基準縮放:頁面先貼合 容器,使用者縮放以此為基準相乘(實際 scale = 基準 × zoom),容器尺寸變動時自動重算。

    • 動機:PDF 頁面的原始寬度(pt)與容器寬度無關,先前恆以 scale=1 渲染,放進抽屜 / side panel(如 512px)必然產生水平捲動,等於抵銷了窄容器的比例優勢
    • 'width':貼合可用寬度(垂直捲動閱讀);'page':整頁貼合;'none':維持原始尺寸 (zoom 即絕對縮放,等同先前行為)
    • single 以當前頁尺寸為基準,continuous 取所有頁的尺寸包絡(混合版面不溢出);旋轉 90° 時以對調後的長寬計算
    • 基準未算出前顯示 spinner,不會先以 scale=1 畫一次再跳動
  • controlsVisibility(PdfViewer,'auto' | 'hover' | 'always',預設 'auto')— 工具列與 頁碼導航的顯示策略。'auto' 在有 hover 能力的裝置維持 hover 才顯示、在觸控裝置恆顯示 (觸控沒有 hover 事件,只靠 hover 等於永遠碰不到控制項,使用者只能猜)。判準為 (hover: hover) media query,與 DataTable / VirtualTable 浮動控制項的既有慣例一致。

  • focusIndicator(MediaViewer,預設 true)— 是否繪製自己的鍵盤焦點指示環。內嵌於自身已有 焦點樣式的宿主(如 MediaInput 的欄位外框 + floating label)時設為 false,避免兩個外框疊在 一起(見 Fixed)。

  • MediaThumbnailOptionProps / MediaThumbnailContainerProps(MediaViewer)— thumbnail 模式改採 roving selection 的無障礙契約。renderThumbnail 的 helpers 多了 optionProps(id / role="option" / aria-selected,需展開在縮圖根節點),renderThumbnailContainer 多了第二個參數 containerProps(role="presentation",需展開在容器根節點)。兩者皆為附加,既有 slot 實作不傳 也能編譯,但不展開就拿不到正確的無障礙語意。

    • 模式與 RichTextEditor 工具列一致:容器是唯一 tab stop,方向鍵移動的是選取而非焦點, 避免每個子項各佔一個 tab stop
  • persistKey(DataTable / VirtualTable)— 欄位版面(欄序 + 顯示/隱藏)的 localStorage 持久化。給了才啟用;使用者調整的版面跨頁面往返 / 重新登入後自動回復,不再每次回到畫面就重設。

    • key 為 appfuse.table-layout.{persistKey},需在應用內唯一(同 key 的多個表格會互相覆蓋)
    • 優先序:已持久化的版面 > defaultColumnOrder / defaultColumnVisibility 種子
    • 欄位定義變動時自動調和:仍存在的欄保留使用者順序、新欄插回自然位置、已刪除的欄移除, 不因加減一欄就丟掉整份版面
    • 容錯:SSR / 隱私模式 / 配額已滿 / 存檔損毀皆靜默退回預設版面,不讓表格連帶壞掉
    • 每瀏覽器 / 每裝置;要跨裝置同步仍走 onColumnOrderChange / onColumnVisibilityChange 自行存後端 + default* 回種
    • 齒輪的「重設版面」會一併把已持久化的版面重設回自然順序 / 全部可見
  • VirtualList(@appfuse/appfuse-web/components)— 虛擬化 + 無限捲動的清單。與 VirtualTable 共用同一套虛擬化捲動核心(useVirtualScroll:虛擬化器、height 解析、無限捲動偵測、回到頂部、 載入指示條),差別只在內容形狀:表格是欄位化的格(表頭 / 排序 / 欄序 / 欄位顯示隱藏才有意義), 清單的一列是不可拆的複合內容、長相全交給 renderItem。

    • 選型判斷句:需要表頭 / 排序 / 欄位操作 → VirtualTable;列是自訂卡片(master-detail 的清單欄、 通知 feed、活動日誌、搜尋結果)→ VirtualList。把複合列硬塞進 VirtualTable 會得到一個 只有單欄、表頭無意義、排序 / 欄序 / 齒輪全部落空的表格
    • Row Selection 與 DataTable / VirtualTable 對齊(同一份 RowSelectionState、同名的 getRowId / selectionMode / getRowCanSelect / onSelectAll / onDeselectAll),但不支援 checkbox(清單的一列不可拆,塞勾選框會破壞 renderItem 的版面;唯一內建操作途徑是 enableRowClick)、也沒有全選鈕(沒有表頭可放,程式化全選走 onSelectAll)。內部不引入 useReactTable,自行管理同形狀的狀態
    • 選中視覺可交還呼叫端:selectionStyle 預設 'accent'(元件套用設計語言的背景 tint + 左緣 3px 強調條,與兩張表的選取列一致);設 'none' 則完全由 renderItem 依第三參數 state.isSelected 自行呈現
    • 列內互動元素不會誤觸選取:enableRowClick 下,來自列內 button / a[href] / input / select / textarea / label / summary / [contenteditable] / 常見 role,以及泛用兜底 [tabindex]:not([tabindex="-1"]) 的點擊與按鍵不冒泡成選取切換——元件承諾「你的列可以有 自己的按鈕」,故不代包 <button>,改在此側攔截
    • 無障礙:可選取的列可 focus、支援 Enter / Space 切換,並有隨 color 上色的 focus ring (與選中標示同色系)。語意維持 role="list" / role="listitem",選中以 aria-current 表達 ——刻意不宣稱 listbox/option(ARIA 規定 option 的內容為 presentational,與「列內有自己的 按鈕」相斥),故也不實作 listbox 的方向鍵導覽。selectionMode="multi" 下多列同時帶 aria-current 是 ARIA 灰色地帶,屬「排除 listbox + 不放 checkbox」前提下的取捨
    • 動態高度:項目不需等高,掛載後由 virtualizer.measureElement 量測修正;estimatedItemHeight 僅為初始估計(預設 56)
    • height 傳百分比時切到 flex/fill 佈局,父容器須有確定高度(flex column 成員或有固定高度的 grid cell / block 皆可)
    • 匯出:VirtualList、VirtualListProps、VirtualListItemState、DEFAULT_ESTIMATED_ITEM_HEIGHT (RowSelectionState 已由 DataTable 匯出,三者共用同一型別)
  • VirtualTable.manualSorting(boolean,預設 true)— 與 DataTable 同名同義,補齊兩支的排序 介面一致性。false 時改為元件內排序(註冊 TanStack 的 getSortedRowModel),可用其內建的 型別感知比較器(alphanumeric 自然排序 / datetime / text)、多欄接續邏輯,以及欄位層級的 sortingFn / sortUndefined / sortDescFirst——這些在後端排序時是啞的。 只在資料全部載入時生效:data 只有一部分時,元件內排序排的是那一部分,得到的順序不反映 整個資料集,故會退回後端排序並於開發模式說明(onSortingChange 照常觸發)。判定「全部載入」 ——VirtualTable 是未提供 infiniteScroll 或 hasNextPage === false,DataTable 是未提供 pagination 或 totalPages <= 1。閘門為即時運算,模式會在 session 中途翻轉(最後一頁載完、 或篩選後總頁數降到 1),翻轉當下會立刻以元件內比較器重排一次。

  • DataTable.getRowId((row, index) => string,選填)— 行的唯一識別碼,selection state 與 React key 共用。未提供時退回「頁面偏移序號」((currentPage - 1) * itemsPerPage + index)而非頁內索引—— DataTable 只支援後端分頁,data 只有當前頁,用頁內索引會讓第 2 頁第 1 列與第 1 頁第 1 列共用 id '0',選取直接跨頁串到錯的資料。序號在換頁之間穩定,但排序/篩選後指向不同資料,故有選取 需求仍應提供業務主鍵。啟用 selection 但未提供時,開發模式會 console.warn 一次 (VirtualTable / VirtualList 同樣加上此提醒)。

Utils (lib/utils/)

  • getApiErrorMessage(error, options?)(@appfuse/appfuse-web/utils)— 從 API 錯誤萃取可顯示訊息。 優先序:violations > detail > title > Error.message > fallback;同時容受 ErrorResponse 實例、未經攔截器轉換的原始 AxiosError(response.data)與序列化後的純物件。 Bean Validation 違規組裝成「欄位標籤:訊息」並就地 i18n——訊息以原文(或 violation.format 模板 + params)為 i18n key;欄位標籤以 field.{propertyPath} 為 key(巢狀路徑 join 成 field.address.city),查無對應時退回原始屬性名 (避免多個必填欄位顯示成數行一模一樣的「不可空白」、使用者無從辨識是哪一欄)。 options 可為 fallback 字串(向後相容的簡寫)或 ApiErrorMessageOptions (fallback / fieldLabelPrefix / fieldSeparator / violationSeparator)。 此前各應用專案各自手寫同一份 helper(src/services/api-error.ts),已上收為框架 util
  • ApiErrorMessageOptions — 上述選項型別

Components (lib/components/)

  • Data Input(11 個組件)
    • Input — 文本輸入框(支援 Floating Label)
    • Select — 下拉選單(單選/多選、搜尋、創建);新增六個 opt-in async 搜尋接縫(對現有靜態用法 dormant),支撐「後端 typeahead → 富列 → 選取/新建」與「search-to-add」——由 React Query 驅動資料:
      • onInputChange(text) — 外露搜尋字供 async 查詢當 queryKey(提供時搜尋框恆顯示;空搜尋時下拉顯示「Type to search...」引導而非「No options found」,避免誤讀為無資料)
      • skipLocalFilter — 關閉內建本地過濾(後端已過濾,避免藏掉非顯示欄位命中)
      • loading — 顯示搜尋中、不顯示空狀態
      • renderOption(option, { active, selected }) — 整列接管自訂內容(頭像/電話/群組 badge、SKU/缺貨 badge);框架管行為、消費端管長相
      • onSelect(option) => boolean — 提交前守衛;回 false 否決提交並清搜尋,下拉收合尊重 closeOnSelect(單選預設收起;搭配 closeOnSelect={false} 保持開啟續搜)→ search-to-add(回 void 為正常 fill)
      • footerAction — 下拉底部持久動作 slot(如「快速新建客戶」,有無結果皆顯示)
      • 下拉內搜尋框改用框架 Input(ghost variant)——無邊框融入下拉、由搜尋列 border-b 分隔,i18n / 主題與其他欄位一致
  • SelectMenu / OptionRow — 從 Select 抽出的下拉「清單主體 / 單列」presentational 子組件,新增為公開 export(@appfuse/appfuse-web/components)。Select 內部下拉與下游 design-system 的 Design Language story 共用同一份,確保選單列外觀(分組標頭、icon、多選 checkbox、已選勾、鍵盤高亮、disabled、renderOption 整列接管)零漂移(對齊 DatePicker 的 CalendarGrid)。純結構抽取、零行為變化
    • Checkbox
    • TagInput — 標籤輸入(maxLength 限制)
    • FileInput — 檔案上傳(PDF、Word、Excel);chip 上點開動作選單(Preview / Download),預覽依 MIME 派發到 MediaViewer(image/video)或 PdfViewer(PDF),不支援類型 Preview 自動 disabled;X 仍為快捷刪除
    • MediaInput — 媒體上傳(圖片/視頻、預覽);可 opt-in 接受 PDF(accept 加入 application/pdf),PDF 以「逐頁圖檔」方式預覽,value/刪除/maxFiles 仍以整份 PDF 為單位
    • RichTextEditor — 富文本編輯器(基於 Tiptap v3 / ProseMirror)
    • DatePicker — 日期選擇欄位(ADR-012):以 Input 為欄位外殼(floating label / variant / density / color / error 全數沿用),popover 月曆的日期可選性由 days: DayStatus[] 資料驅動——接後端具名行事曆 resolve() 的解算結果({date, selectable, reasonCode, description},鏡射 appfuse-server calendar 契約),不可選日期灰字(週休類加刪除線)、圖例彙整當月 reasonCode(經 i18n)、description 透傳為 tooltip;元件零行事曆邏輯、不打 API。日期值容忍 string | Date(days[].date、value/defaultValue)——框架 http client 對無時區的日期(LocalDate / LocalDateTime)保留 ISO 字串、僅將帶時區的 instant 轉為 Date,元件內部對 string | Date 自行正規化,應用層零轉換;base 版 onChange 輸出 ISO YYYY-MM-DD 字串(UI primitive 中性形狀)。onRangeChange 於顯示月份跨出資料涵蓋範圍時通知應用層補載;disabledDate predicate 僅供純前端表單內約束逃生口;renderDay 自訂 day-cell 內容。月曆導航頭點標題可開年/月快速跳選(月選單一次選該年 12 個月、年選單每頁 12 年翻頁),選遠年不必逐月點擊;選單開啟時隱藏「上/下個月」箭頭(避免兩排箭頭);日層附**「今天」**按鈕一鍵跳回今天所在月份。同時匯出底層 CalendarGrid(月曆網格渲染器,day-cell render prop 為未來事件行事曆檢視的擴充縫)、CalendarNav(導航頭 + 年/月快速選單,包住 CalendarGrid 供 DatePicker / DateTimePicker 共用)與 DayStatus / RenderDayContext / CalendarNavProps 型別
    • TimePicker — 時間選擇欄位:以 Input 為欄位外殼,自建時 / 分(可選秒)欄位 popover(不使用原生 <input type=time>,跨瀏覽器一致)。值為無時區 LocalTime 字串(HH:mm / HH:mm:ss,對齊後端 LocalTime);minuteStep / secondStep 控 popover 選項密度、withSeconds 顯示秒欄。匯出 parseTime / formatTime / nowParts 工具與 TimeParts 型別
    • DateTimePicker — 日期時間選擇欄位:單一欄位、單一 popover 組合月曆(重用 CalendarGrid 與行事曆 DayStatus 資料驅動,ADR-012)+ 時間欄。值為無時區 LocalDateTime 字串(YYYY-MM-DDTHH:mm[:ss],對齊後端 LocalDateTime),不轉 Date;沿用 DatePicker 的 days / onRangeChange / disabledDate / renderDay 與 TimePicker 的 withSeconds / minuteStep / secondStep;讀取端寬容 Date 值(以本地牆鐘拆解顯示)。timeColumnHeader slot 可在時間欄頂部(h-8 與月曆導航列對齊、下方留間距)附掛輔助控制(如 InstantPicker 的時區選單);未用此 slot 時 Hour/Minute 標籤即與月曆導航列(月年/今日/箭頭)對齊、清單緊湊列高向下延伸。popover 主體(月曆+時間欄)抽為共用呈現元件 DateTimePanel(一併匯出),供元件與其 Storybook Design Language 快照共用同一份版型、杜絕漂移。匯出 splitDateTime / combineDateTime 工具與 DateTimeParts / DateTimePanelProps 型別
    • InstantPicker — 絕對時刻(Instant)選擇欄位:UI 承 DateTimePicker(月曆 + 時間欄),但值為帶時區的絕對 Date——使用者選的牆鐘以選定時區解讀成絕對瞬間(對齊後端 Instant;Date 送出時由框架 http client 序列化成帶 offset 的 ISO)。時區可由 App 指定並開放使用者選:timeZone prop 指定解讀牆鐘的時區(預設瀏覽器時區)、popover 時間欄頂部(與月曆導航列對齊)一律顯示時區——只顯示時區名稱(無標籤、無 GMT;GMT 於選項與 tooltip):傳入 timeZones 清單時為多選可改的 ghost 選單(使用者覆寫)、未傳時為同款 ghost 但僅單一選項(唯讀)——兩者版型/置中一致;改時區採「牆鐘不變、瞬間改變」語意。onChange(value: Date | null, timeZone: string) 同時回報瞬間與選定時區(後端 Instant 只需瞬間,timeZone 供需另存檢視時區的場景;既有只讀第一參數的呼叫端相容)。用於「使用者輸入一個絕對時刻」(如排程「在此刻執行 / 發布」)、跨時區需指向同一瞬間的場景;需無時區鐘面日期時間請改用 DateTimePicker。匯出 browserTimeZone / timeZoneOffsetMinutes / instantToZonedWallClock / zonedWallClockToInstant / formatTimeZoneLabel 任意 IANA 時區換算工具(含 DST);wallClockToInstant 標記 @deprecated(改用 zonedWallClockToInstant(value, browserTimeZone()))
  • Data Display
    • Badge — 狀態/標籤 chip(@appfuse/appfuse-web/components)。基於 DaisyUI badge,whitespace-nowrap 內建——狀態文字一律單行不折行(窄欄如 DataTable 狀態欄也不拆行)。支援 8 種語意顏色(color:default / primary / secondary / accent / info / success / warning / error)、3 種填色樣式(variant:solid / soft / outline)與 5 種尺寸(size:xs–xl)。取代此前各處手寫的 <span className="badge …">。一併匯出 BadgeProps、BadgeVariant 型別與 badgeVariants(cva)
    • Icon(基於 Lucide React)
    • MediaViewer(輪播、全屏);採「外殼 + 每格式檢視元件(Image/Video/PDF)」策略架構,展開操作選單隨格式變化;支援以 PDF 承載的圖檔(一個 PDF = 一個項目);導航分層——外殼底部 ‹ ›+圓點換檔案(←/→),PDF 翻頁由 PDF 自帶頁碼列處理(PageUp/PageDown)
    • PdfViewer(PDF 文件檢視;single / continuous 模式、縮放、旋轉、縮圖、下載、列印、文字搜尋;依賴 pdfjs-dist peerDependency)
    • DataTable / VirtualTable 浮動動作選單(speed-dial)——右上角把多個表格檢視動作收合成單一齒輪鈕(點擊放射狀展開、往左下象限,視覺語言對齊 MediaViewer 弧形控制;只有一個動作有意義時則直接顯示、不套齒輪)。齒輪固定於右上角、不支援拖曳(方向固定、無需象限偵測)。預設 hover 表格才淡入(鍵盤 focus / 展開中 / 無 hover 能力的觸控裝置恆顯示),把畫面收乾淨。兩表共用同一實作(內部 TableActionSpeedDial)+ hover 邏輯共用 useTableHoverReveal。動作鈕給不透明表面底色 + 邊框(bg-base-100),壓在資料列上仍清楚可辨(不因透明底混入底下文字);所有動作鈕外觀一致——切換類動作(序號欄 / 全螢幕)的開啟狀態以 aria-pressed 表達、不加視覺外框(狀態直接反映在表格本身:序號欄出現/消失、全螢幕展開)。
      • 齒輪動作(依序):首個動作依表格而異(互斥)——DataTable 為每頁筆數(下拉選 10/20/50…,勾選標示目前值;onPageSizeChange 回調、pageSizeOptions 選項),VirtualTable 為重設(欄位可見性 + 拖動改變的欄序歸位,不動排序);其後共同為 → 全螢幕(CSS 展開至整個視窗,Esc 退出)→ 序號欄顯示切換 → 欄位可見性(下拉勾選)。showTableActions(預設 true)為單一總開關;元件自動抑制沒意義的動作(無可隱藏欄位 → 不顯示欄位動作;DataTable 無分頁 → 不顯示每頁筆數;序號欄以 showRowNumber 為初始值於選單即時切換)。設 false 整組關閉(卡片內唯讀小結表、列印表格等)。
      • 載入狀態不在齒輪:VirtualTable 的無限捲動進度改以表格右側中間的垂直「載入指示條」呈現(showFloatingStatus 控制,role="progressbar" 帶 aria 值)——指示條依載入進度由下往上填充(視為橫向進度條順時針旋轉 90° 後的方向)、與右上角齒輪同一垂直線對齊、hover 表格才顯示(與齒輪 / 回到頂部一致;觸控裝置恆顯示),載入中時填充 pulse。採 DataTable PaginationRail 的視覺語言(軌道 + 旋轉 90° 小標籤)並於畫面直接標示筆數:已載入筆數跟著填充頂端上移、總筆數固定於頂端(=100% 全載入的目標位、接近全載入時淡出讓位)。筆數既於畫面直接標示,指示條不再掛 hover title(role="progressbar" 的 aria-label 仍保留給螢幕報讀)。無總數時退回純文字顯示已載入數。與 DataTable 位置指示條外觀相同、語意不同——此處是往完成累積的進度填充(已載入 / 總數),DataTable 是不累積的位置(第幾頁)。(DataTable 與 VirtualTable 的齒輪首個動作刻意不同——見上「齒輪動作」。)
      • VirtualTable 的回到頂部維持右下角獨立按鈕(一鍵、hover 才現,尺寸與齒輪一致)。
      • DataTable 分頁器重構為右側垂直控制(PaginationRail,取代原本表格下方那排)——與 VirtualTable 的垂直指示條同一垂直線(right-2)、置中:中央為捲軸式「位置指示條」(滑塊高度 = 一頁佔總頁數的比例、最小 12% 保持可見;位置隨頁碼由上往下滑,第 1 頁貼頂、末頁貼底),標示「目前在第幾頁」的位置感而非進度;hover 滑塊以 title 顯示「目前頁 / 總頁數」。指示條左側顯示總筆數(totalItems,垂直置中)、右側顯示本頁筆數範圍(1-10,垂直位置跟著滑塊移動、隨頁碼上下滑),兩者皆順時針旋轉 90°。指示條可操作(捲軸式):點擊軌道滑塊上方 → 上一頁、下方 → 下一頁;拖曳滑塊換頁——拖曳中即時預覽(滑塊、範圍、頁碼跟著指標動),放開才提交(避免後端分頁拖曳中每經一頁就打一次 API 的請求風暴)。滑塊兩端各一顆翻頁按鈕(上=上一頁、下=下一頁,首/末頁對應鈕 disabled;鍵盤/無障礙走按鈕,指示條的點擊拖曳為滑鼠/觸控增強)。分頁器恆顯示(不需 hover),且僅在總頁數 > 1 時出現,上/下留白讓使用者一眼知道還有其他頁。與 VirtualTable 的「載入指示條」同屬右側垂直指示條、外觀相似但語意區分:VT 是由下往上填充的載入進度(已載入 / 總數,會累積往完成),此處是位置(你在第幾頁,不累積)。移除表格下方那排(含「1-10 of 187」資料範圍文字);每頁筆數改由右上角齒輪的「每頁筆數」動作提供(下拉選 pageSizeOptions、onPageSizeChange 回調,取代原下方下拉)。
      • per-action 的欄位可見性 / 密度 / 重置版面 / 全螢幕開關統一為上述 showTableActions(被取代的舊 prop 見下方 Deprecated 與 Removed 段落)。
  • Actions
    • Button(4 變體、9 顏色、loading、confirmation)
  • Charts(基於 nivo + d3-scale-chromatic)
    • LineChart / BarChart / PieChart / AreaChart / RadarChart / ScatterPlot / FunnelChart / ParallelCoordinates
    • 主題透過 useChartTheme() 跟隨 DaisyUI 主題(座標軸 / 格線 / 刻度文字 / tooltip 自動套用 --color-*,切換 data-theme 即時重算)
  • Feedback
    • MswHealthGuard — 守衛 MSW 攔截健康狀態的 app-shell 元件。Prototype 的 Mock Service Worker 會被瀏覽器閒置終止或在背景分頁被換掉,導致回到分頁後請求穿透報錯、需手動重整;本元件在分頁重新可見時以 navigator.serviceWorker.controller 偵測攔截是否失效,失效時依 recovery 引導復原('prompt' 預設顯示非阻斷提示條 + 一鍵重載 / 'reload' 自動重載,內建防迴圈)。無 MSW 依賴、以 enabled 對齊 MSW 旗標,生產關閉 MSW 時完全惰性;新增 export MswHealthGuardProps、MswHealthGuardRecovery 型別
    • UnderConstruction — 「施工中 / 即將推出」整區佔位元件,供生產前端漸進式上線時,尚未啟用功能的路由渲染一致的佔位畫面(title / description 自動 t() 翻譯,可自訂 icon / action)
    • Toast 新增 variant prop('solid' | 'soft' | 'outline',預設 'solid'),對應 daisyUI alert style modifier;MessageToast 同步新增 variant prop 透傳至全域 Toast 訊息;新增 export ToastVariant 型別

Form Integration (lib/form/)

  • 日期時間 picker 新增公開辨識式 props 型別:InstantDatePickerProps、LocalDatePickerProps、 InstantDateTimePickerProps、LocalDateTimePickerProps,可從 @appfuse/appfuse-web/form 匯入。
  • 11 個表單集成組件(react-hook-form + 框架 schema)
  • 支援類型轉換(number)、i18n 標籤翻譯、統一錯誤處理;date / datetime-local 保留 ISO 局部字串(LocalDate / LocalDateTime 形狀),不轉 Date
  • Input(type=date / datetime-local)— 表單值為 ISO 局部字串(YYYY-MM-DD / YYYY-MM-DDTHH:mm[:ss]),對應後端 LocalDate / LocalDateTime,不轉 Date(無時區的日曆日/鐘面時間塞進帶時區的 Date 是跨時區 off-by-one 根源);顯示端寬容(既有 Date 值亦可格式化顯示)
  • DatePicker(form 版)— useController 綁定的日期選擇:表單值為 ISO YYYY-MM-DD 字串 | null(LocalDate 形狀,即後端 LocalDate 的 JSON 形狀,request 免轉換),不轉 Date;讀取端寬容(既有 Date 值亦可顯示);新增 export useDatePicker hook 與 UseDatePickerHints / UseDatePickerModifiers 型別
  • TimePicker(form 版)— 表單值為 LocalTime 字串 | null(HH:mm / HH:mm:ss,即後端 LocalTime 的 JSON 形狀);新增 export useTimePicker 與 UseTimePickerHints / UseTimePickerModifiers 型別
  • DateTimePicker(form 版)— 表單值為 LocalDateTime 字串 | null(YYYY-MM-DDTHH:mm[:ss],即後端 LocalDateTime 的 JSON 形狀),不轉 Date;讀取端寬容 Date 值;新增 export useDateTimePicker 與 UseDateTimePickerHints / UseDateTimePickerModifiers 型別
  • InstantPicker(form 版)— 表單值為絕對 Date | null(Instant;牆鐘 × 瀏覽器時區),送出由 http client 序列化成帶 offset ISO、對齊後端 Instant;讀取端寬容 ISO 字串(轉 Date 顯示);新增 export useInstantPicker 與 UseInstantPickerHints / UseInstantPickerModifiers 型別

Utils (lib/utils/)

  • ajax、i18n、time、logger、cn、cookie、environ、browser、numeral、template
  • createHttpClient 新增 auth 配置,把 401 → refresh → retry 變成框架一級能力。提供後框架在「錯誤轉成 ErrorResponse 之前」攔截 401,自動完成 token refresh、single-flight(多個並發 401 只觸發一次 refresh)、以新 token 重放原請求、_retry 防遞迴,並在 request 端依 getAccessToken() 附上 Authorization: Bearer。配置只收 callback(getAccessToken / refresh / shouldAttach / onAuthFailed),不綁定任何儲存實作(localStorage / Redux 皆可),維持框架中性;refresh 最終失敗時呼叫 onAuthFailed() 後仍以 401 ErrorResponse reject(既有錯誤契約不變)。新增 export AuthConfig、AuthTokens 型別。解決下游後加的 response 攔截器只能收到 ErrorResponse(.status)、拿不到原始 AxiosError(.response.status),導致無法乾淨實作 401 refresh 的問題
  • createHttpClient 的 auth 新增 proactiveRefresh 選項(boolean | { skewSeconds },預設開啟;false 關閉):在送出請求前解碼 access token 的 JWT exp,若已過期或將在 skewSeconds(預設 30)內過期,先 refresh 再附新 token 送出——常態下省去「注定 401 的請求」往返與後端的合法 401 雜訊。與被動 401 refresh 共用同一個 single-flight(被動仍作為安全網);access token 非可解碼 JWT 時靜默跳過、退回純被動,維持框架中性

Hooks (lib/hooks/)

  • useTimeout、useInterval
  • useDebounce<T>(value, delay) — 回傳防抖後的值;用於「搜尋框 → async 查詢」(把輸入經 debounce 再當 React Query 的 queryKey,避免每個 keystroke 都打後端)

Messaging (lib/messaging/)

  • 全局消息系統:prompt-bar、prompt-dialog、prompt API

Development Tools

  • Storybook 10、Vitest 3、Playwright 1
  • TypeScript 嚴格模式
  • ESLint 9 + Prettier 3

Design System

  • DaisyUI 5.3 主題系統(30+ 主題)
  • Floating Label 統一模式
  • 語義顏色系統(text-base-content、bg-base-100 等)
  • Addon 插槽模式(leftAddon、rightAddon)

Tooling / CLI

  • 新增 appfuse-ds-snapshot bin(cli/snapshot-design-system.mjs)—— design-system 快照工具,把模組 Storybook 的設計系統 stories 渲染成單一自包含扁平 HTML,供 /design-language push 鏡像進 Claude Design。從消費端 mockup 模組透過 npm bin 執行(root = process.cwd(),模組身分與主題由所在目錄與 .storybook/preview.tsx 推導,project-agnostic)。下游 mockup 模組改用此 bin 取代各自 scripts/ 內的快照腳本複本(npm script:"snapshot:design-system": "npm run build-storybook && appfuse-ds-snapshot")。

RichTextEditor​

  • 新增雙層 toolbar 客製化 API,支援下游領域特化按鈕注入:
    • Level 1: toolbarExtras?: (editor: Editor) => ReactNode — 在內建 toolbar 末端(最後一個內建按鈕群與 spacer 之間)注入自訂內容,保留 a11y / 鍵盤面板 / 密度變體
    • Level 2: toolbar?: boolean (default: true) — 設 false 完全停用內建 toolbar,下游透過 getEditor() 自行渲染外部 toolbar
    • 對應公開 export:ToolbarButton / ToolbarDivider 元件(含 ToolbarButtonProps 型別)與 RichTextEditorHandle 型別
    • ToolbarButton 採 onClick API,內部以 onMouseDown + preventDefault 實作避免 editor 失焦
    • 範例與選擇指引見 lib/components/data-input/rich-text-editor/README.md 的「自訂 Toolbar」段
  • 新增 extensions?: AnyExtension[] prop,允許下游專案注入自訂 Tiptap extensions(自訂 node、mark、commands、input rules、suggestion 等)
    • 用途:應用領域特化的編輯器擴充(如數學公式、模板變數、mention、自訂 block)
    • 框架本身不內建數學公式 / 模板變數 / mention,避免不必要依賴與 API 偏向;下游各自實作或抽成獨立套件
    • 讀取時機:只在 editor 初次建立時讀取,後續 prop 變動會被忽略(ProseMirror schema 在 editor 建立時鎖定);如需動態切換請給 <RichTextEditor> 一個會變化的 key 強制 remount
    • 觸發 UI:appfuse-web 的內建 toolbar 不開放擴充,下游應透過 input rules / suggestion plugin(鍵盤觸發)或在外圍包一層自家 toolbar 並透過 getEditor() 呼叫 commands
    • 範例與最佳實踐見 lib/components/data-input/rich-text-editor/README.md 的「自訂 Extensions」段

Checkbox​

  • Checkbox 與 CheckboxGroup.options[] 新增 icon?: ReactNode,可在 checkbox 方框與標籤文字之間渲染圖示(搭配 lucide-react 等 SVG icon),對齊 Radio 的相同 API;典型場景為設定面板(☑ 📧 訂閱電子報 / ☐ 🌙 夜間模式 / ☑ 🔒 加密儲存)
  • Checkbox.hideLabel 行為微調:仍提供 icon 時圖示不受影響,僅隱藏文字(支援 icon-only 視覺呈現),對齊 Radio.hideLabel
  • 新增 Checkbox.WithIcon story 示範 4 個帶 icon 的設定切換場景
  • MUI Checkbox 沒有對應 prop(其 icon / checkedIcon 是替換 checkbox 方框本身,不是 label 前的裝飾 icon),AppFuse 的 icon prop 屬於框架便利擴充

Radio​

  • Radio 與 RadioGroup.options[] 新增 icon?: ReactNode,可在 radio 圓圈與標籤文字之間渲染圖示(搭配 lucide-react 等 SVG icon)
  • Radio.hideLabel 行為微調:仍提供 icon 時圖示不受影響,僅隱藏文字(支援 icon-only 視覺呈現)
  • 單體 Radio 新增 inline?: boolean:wrapper 改為 inline-flex flex-col align-[-0.25em] mx-[0.25em],可嵌入 <p> 中文 prose 不打斷段落;同 name 的多個 inline Radio 形成 prose 內嵌的單選題(仿 commit 971efd2 的 Checkbox inline 設計)
  • RadioGroup / CheckboxGroup 新增 inline?: boolean 佈局模式
    • 預設 false:既有行為(label 在上、選項在下、direction 控制選項橫直)
    • inline=true:label 與選項排在同一列,自動套用 pt-3 + min-h-[60px](comfortable,對齊 Input bordered 浮動 label 留白 + 高度)或 pt-3 + min-h-[52px](compact);direction 視為 'horizontal'
    • 與 hideLabel 正交:inline + hideLabel 可表達「只有橫排選項、label 僅供 screen reader」(用於不需要 label 顯示但要對齊 Input 的情境,如付款方式放在 Customer Select 旁邊)
  • 新增三個 Stories:MuiBenchmark(density / color / rowGap controls,AppFuse 與 MUI 並列校準)、InlineEdit(prose 內嵌 inline Radio 同 name 互斥 + 結構化 RadioGroup)、TableEdit(表級全表單選的「主要」欄 + cell 內局部 RadioGroup)

Switch​

  • Switch 新增 icon?: ReactNode,可在 toggle 與標籤文字之間渲染圖示(搭配 lucide-react 等 SVG icon),對齊 Checkbox / Radio 的相同 API;典型場景為設定面板(🔔 推播通知 / 🌙 深色模式 / 💾 自動儲存 / 🛡️ 加密儲存)
  • Switch.hideLabel 行為微調:仍提供 icon 時圖示不受影響,僅隱藏文字(支援 icon-only 視覺呈現),對齊 Checkbox / Radio.hideLabel
  • Switch 新增 inline?: boolean:wrapper 改為 inline-flex items-center align-baseline mx-[0.25em] 的 <span>,可嵌入 <p> 中文 prose 不打斷段落;與 Radio 不同的是 Switch 每個都是獨立布林(無同 name 互斥),適用使用者協議、權限同意書等「逐項勾選同意」場景。對齊細節採 align-baseline(不沿用 Checkbox / Radio 的 align-[-0.25em])—— daisyUI .toggle 的 box model(track padding + handle 定位)與 .checkbox / .radio 不同,沿用會讓 toggle 視覺中心整體偏低 4px
  • 新增三個 Stories:MuiBenchmark(density / color / rowGap controls,AppFuse 與 MUI 並列校準,含「Input + Switch 並排」對齊驗證)、InlineEdit(prose 內嵌 inline Switch 獨立布林 + 結構化 grid 雙向綁定)、TableEdit(表格內聯 Switch 啟用 / 通知 / 自動同步並陳,含依賴關係連動 disabled);新增 WithIcon story 示範 4 個帶 icon 的設定切換場景
  • MUI Switch 沒有對應 icon prop(其 icon / checkedIcon 是替換 toggle thumb 本身,不是 label 前的裝飾 icon),AppFuse 的 icon prop 屬於框架便利擴充

MediaInput​

  • MediaInput.stories.tsx 新增 MuiBenchmark story,與其他 Data Input 元件對齊(先前唯一缺 MuiBenchmark 的元件):density / variant / rowGap controls + AppFuse 與 MUI 風格媒體選擇器並列對照,含 Input + MediaInput 並排校準、單檔(hero 主圖)與多檔(gallery thumbnail)模式、required + error / readOnly / disabled 完整狀態示範
  • 內含自訂 MuiMediaPicker 替身元件(MUI 無原生 MediaInput):以 <fieldset> + <legend> 模擬 MUI TextField outlined 的 notch 邊框缺口效果,配 IconButton 上傳鈕、img 縮圖網格,提供與 AppFuse MediaInput 視覺對等的對照組

Layout​

  • Fieldset — HTML 語意正確的表單分組容器(lib/components/layout/fieldset/)
    • 內建 daisyUI fieldset + fieldset-legend class,並覆蓋兩個易踩雷的預設值:
      • .fieldset 預設 font-size: 0.75rem → 強制 text-base,避免內容文字被壓小
      • .fieldset-legend 預設 font-semibold → 改 font-normal,多 fieldset 頁面視覺更協調
    • legend prop 為 string 時自動 t() 翻譯(同 Input/Textarea label 慣例);ReactNode 原樣渲染
    • 變體:bordered(預設,card-like chrome)/ divider(legend 後接水平線,章節標題感)/ section(下緣 groove 樣式 trailing rule,末段自動省略;groove 凹陷溝槽效果與 input/select 的實線邊框視覺區隔)
    • 密度:comfortable(預設,p-4 + text-base,對齊 input-lg)/ compact(p-3 + text-sm,對齊 input-md);命名與字級皆與 Input / Select / Textarea 對齊,方便整頁「fieldset 與內含表單欄位的 density 一致」設計
    • icon?: ReactNode — legend 前綴圖示,尺寸隨 density 自動對齊字級(comfortable=16px / compact=14px),呼叫端不需手動設定 SVG 寬高
    • divider / section 變體 + string legend + 未提供 icon 時,自動套用 Diamond 凸顯章節標題(ReactNode legend 或 bordered 變體不套預設,避免覆蓋既有 inline icon 設計)
    • divider 變體的 legend 移除 px-2,讓 legend 內容(icon、文字)與 fieldset body 內容左對齊(過去會有 8px 內縮錯位);bordered 變體保留 px-2 維持與斷開邊框的間距
    • divider 變體 trailing line 修正:移除 ::after 上的額外 margin,僅靠 daisyUI .fieldset-legend 預設的 gap: 0.5rem(8px)拉開間距。先前 margin 與 gap 疊加導致 trailing line 看起來離 legend 過遠(實際 16px,預期 8px)
    • 取代 applet 端的 <fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4 text-base"> 樣板
  • Card — 通用「有標題的區塊卡」(lib/components/layout/card/):標頭(icon + title + actions)→(可選)分隔線 → 內文的三段式語意容器(<section>,非 <fieldset>)
    • step 標頭只是傳入的 icon,故表單區塊卡與摘要/資訊卡為同一元件
    • divided(預設 true)標頭下分隔線,滿版頂到左右內邊框(結構為「無 padding 容器 + header 區塊 + body 區塊」,header 與 body 各自 padding、可不同)
    • surface('base-100' | 'base-200' | 'base-300',預設 base-100)以表面對比造就立體感(白卡浮於灰底、非陰影);頁面內容區用 bg-base-200 即凸顯白卡
    • collapsible 吸收原 CollapsibleCard 的收合能力(defaultExpanded / summary / toggle chevron);不開即靜態卡
    • role="group" 時自動以標題 id 綁 aria-labelledby,達成等同 <fieldset>/<legend> 的分組無障礙、但不佔用 fieldset 語意(嚴格 fieldset 語意仍用 Fieldset)
    • density(comfortable = p-4+text-base / compact = p-3+text-sm)與 title 字重 font-normal 皆與 Fieldset 對齊;匯出 CardProps / CardDensity / CardSurface 型別
    • 取代並移除 CollapsibleCard(見 Removed)

Changed​

  • PrototypeToolsLink/右下角工具入口使用主題的 primary 色,保留 outline 與 tight 樣式。

  • PrototypeToolsPage 統一提供可嵌入內容,頁面寬度、留白、背景與高度由應用外框決定; 移除內建的 1080px 最大寬度、置中、外層內距、背景與視窗最小高度,避免與應用容器疊加。 既有 <PrototypeToolsPage /> 用法不變;獨立 route 的消費端須自行提供頁面容器。

  • PrototypeToolsPage 將「進階異常情境」簡化為「異常情境」(Fault scenarios),預設展開並保留手動收合。

  • PrototypeToolsPage(19.0.0-alpha.141)移除重複產品導覽的入口卡片,將「重新開始」移到頁首右側的次要按鈕; 清除範圍與重建說明集中在既有確認視窗,保留取消/Escape 焦點返回、送出鎖定與失敗重試。 信箱、情境與預設收合的進階故障維持主體;故障恢復仍在啟用中的警示旁,保留既有資料。 不新增設定,manifest links 欄位保留相容;Alt+Shift+P 工具入口快捷鍵維持原行為。

  • Prototype 體驗生命週期自動配合 Native Session(19.0.0-alpha.140):匿名體驗仍閒置 120 分鐘, 登入後保留至原 Native Session 的有效期限;保持登入時,私密 Scope Cookie 同步為持久 Cookie。 初始化不把已保持登入的 Scope Cookie 改回工作階段 Cookie;登出/撤銷後回到匿名期限。 只讀 Native store 的期限資訊,不解析憑證、不新增消費端設定、不改原固定/滑動認證期限; 背景查詢不額外續命,128 份容量與逐出/重設/晚回防護維持。

  • 開發模式自動啟用區網 HTTP Session 相容(19.0.0-alpha.138),不需要新增設定。 由消費端建置工具的 process.env.NODE_ENV === 'development' 判定;npm library build 保留判斷供應用處理,非開發或未知模式維持 strict。只有非安全 HTTP 使用同 context 佇列; localhost/HTTPS 等安全來源仍要求 Web Locks。既有 strict/crossTab 明示策略保留; alpha.137 的 development-http 選項亦受開發模式限制,不得在非開發模式強制降級。 診斷、warning、family fence、取消後 drain、認證及憑證儲存規則保持原保證。

  • Toast(19.0.0-alpha.135)預設採緊湊構圖:上下 8px、左 16px、右 8px 內距,短訊息+單一無框文字 action 可同行; 標題、多動作、長訊息或窄容器時改為下排/換行,保留完整內容與原回呼,不固定高度或裁切文字。 32px 關閉鈕、severity/variant、shadow-lg、role=alert/aria-live polite 保留。 ToastContainer 堆疊間距改為 6px,偏好最小寬度 340px、最大 512px;窄視窗縮入既有 20px 留白範圍。

  • Button.loading(19.0.0-alpha.135)改用 18px 固定圓弧 SVG、1 秒線性旋轉及 8px 內容間距,避免動態 mask 縮成小點。 loading 保留原生 disabled/aria-busy 與原按鈕配色、文字,滑鼠停留顯示 progress;Soft 維持透明底, Link 以行內 flex 對齊 spinner 與文字、維持行內高度。僅 loading 恢復滑鼠命中以顯示游標,原生 disabled 仍阻止操作。

  • Dialog 內容區 padding 改為 px-4 py-3(水平 1rem、垂直 0.75rem),同步 Storybook Design Language 預覽。

  • SignatureInput 的 Components/Forms 版改用 Data Input 浮動標籤:空白且未聚焦時在書寫區 左上(與 Textarea/RichTextEditor 第一行位置一致),聚焦或有筆跡時縮小浮起,bordered 使用邊框缺口。清除改為欄位右上角具可及性名稱與鍵盤操作的圖示按鈕;固定保留獨立操作區, 不遮擋筆跡。空白停用、唯讀/停用隱藏及清除回傳 null 的契約維持;書寫區寬度會扣除 操作區,既有向量仍等比例完整呈現。 color 預設由 'primary' 改為 'default',語意對齊 Input 系列:未指定時外觀不變 (中性外框、聚焦 primary);指定其他色時一般狀態外框也使用該色。錯誤時聚焦標籤與 外框一律使用 error 色。

  • Data Input 欄位名稱標籤與次要文字由 text-base-content/60(部分為 /50)調為 /70, 修正預設 fusion 主題(原 4.05:1)與 cupcake(原 4.45:1)低於 WCAG AA 4.5:1 的文字對比。 涵蓋 Input(含 DatePicker、TimePicker、DateTimePicker)、Textarea、Select、 TagInput、FileInput、MediaInput、RichTextEditor、SignatureInput 的浮動標籤, CheckboxGroup、RadioGroup、OtpInput 的欄位標籤,以及 helperText、Textarea 字數、 Input 左側 addon、Select 分組標題、上傳/下載提示與日期時間面板的欄位標題與說明列。 聚焦色不變。valentine 主題的 base-content 在 /80 仍只有 4.41:1,次要文字需由主題調整。

  • RichTextEditor、MediaInput 的 bordered 一般狀態外框對齊 Input 系列:預設由 base-300 改為 base-content/20,指定 color 時一般狀態即套用該色,錯誤時不需聚焦即顯示 error 色外框 (MediaInput 原本錯誤時只在聚焦後變色)。FileDownload 下載成功狀態改為內容色文字搭配 success 色圖示,與錯誤訊息相同模式。

  • Data Input 錯誤訊息改為內容色文字搭配 error 色提示圖示,錯誤語意由 error 色外框與圖示 承擔:error 色作為文字在 light(2.86:1)、garden、pastel、cupcake、cyberpunk 等主題低於 WCAG AA。涵蓋 Input、Textarea、Select、TagInput、Checkbox、CheckboxGroup、 Radio、RadioGroup、Switch、OtpInput、RichTextEditor、FileInput、MediaInput 的欄位錯誤與檔案驗證錯誤、FileDownload 下載失敗狀態及 RichTextEditor 圖片上傳錯誤; SignatureInput 改用相同圖示。Checkbox、Radio、Switch 的選項文字在錯誤時及指定 color="error" 時都維持內容色,不再轉紅。 Input、Textarea 的 ghost 變體錯誤時不再把輸入文字轉為 error 色,改在欄位底部顯示 error 色 底線:呼叫端以 className 加 border-b 時該線轉為 error 色,未加時自動補 1px 底線,不改變 版面高度;Tooltip 錯誤訊息不變。

  • FileInput、Select 多選、TagInput 的欄位內 Chip 統一使用 size="sm"(13px 字/24px 高), 包含 compact、comfortable 與 ghost;+N/收合控制共用相同尺寸。 density 僅調整欄位高度與留白,不再放大 Chip;單列 bordered 維持 40/48px, ghost 與換行欄位依較小的內容自然收縮。欄位內距隨主題邊框厚度補償, 避免 cupcake 等較厚邊框將 compact 欄位撐高。移除 Chip 內部的 density 配置。

  • Badge/Chip 明確定義行高並僅將文字向上光學校正 1px;圖示、刪除鈕與外框高度維持原規格。 Chip 的 13px/15px 字分別採 18px/20px 行高,inherit 隨字級同比例縮放;Badge 的 xs/sm/md/lg/xl 行高為 14/18/20/24/28px。FileInput、Select、TagInput 的 chip 仍採 13px 字,欄位內統一使用 sm 尺寸(見上方)。

  • Badge 與 Chip 統一膠囊外型:rounded-full 移至 Badge 共用基底,Chip 沿用。 Badge 預設維持 24px 高、Chip 預設維持 32px 高,字級、內距與 variant 預設值不變。

  • Badge 預設 size="md" 字級由 14px 調整為 15px;xs/sm 均為 13px,以維持可讀文字下限; lg/xl 維持 16px/18px。Chip 獨立使用時預設字級由 13px 調整為 15px; TagInput、FileInput、Select 的欄位內 chip 維持 13px,尺寸統一採 sm。 直接使用 DaisyUI badge class 的應用程式不會自動套用框架 Badge 的字級規格。

  • PdfViewer(含 ReportViewer)以 HTTP Range 按需載入 API URL 的 PDF:同源或 API URL 先以 Range: bytes=0-262143 探測,伺服器回 206 時只讀取畫面需要的區段(PDF.js range transport, 區段 256 KiB,關閉 auto fetch 與 stream),第一頁在全檔下載前顯示;回 200 時沿用完整內容載入。 區段請求一律經 HttpClientProvider 的 client,沿用 Bearer、401 refresh 與 cookie 設定。伺服器回應的 Content-Range、長度或 Content-Encoding 不符時以錯誤結束,不把錯誤位元組交給 PDF.js;有強 ETag 時續讀帶 If-Range,文件在讀取中變更會顯示錯誤而非拼接新舊內容。卸載或換來源時取消進行中的請求。 伺服器端應以 appfuse-server 的 FileResponseBuilder...request(request) 回應下載端點。

  • PdfViewer continuous 模式改為 windowing:只掛載可視範圍附近的頁(上限 memoryLimits.renderedPages), 其餘以依頁面尺寸計算的占位維持捲軸長度;尚未量測的頁以第一頁尺寸估計,量測後保持目前閱讀位置。 縮圖側邊欄同樣只掛載可視範圍附近的縮圖。continuous 模式的適頁寬度/適頁改以第一頁尺寸計算,不再等待 所有頁面尺寸。

  • PdfViewer 文字搜尋改為依序抽取頁面文字、逐頁回報結果,搜尋框顯示「Searched n of N pages」; 變更關鍵字不會中斷已開始的抽取,已抽取的頁面文字可重用。

  • PdfViewer 下載改經 FileDownloader(API URL 帶認證完整下載,不依賴已載入的區段);列印改為取得 完整內容後以隱藏 iframe 呼叫 print()。瀏覽器沒有內建 PDF 檢視器(navigator.pdfViewerEnabled === false) 時改為下載檔案;iframe 30 秒內未載入即清理並回報失敗。

  • Tooltip 開啟時以 aria-describedby 把內容關聯到 trigger(保留 trigger 既有的 aria-describedby, 如欄位 helper text),關閉後還原;開啟期間按 Esc 可關閉(hover 或 focus 開啟皆適用,對齊 WCAG 1.4.13)。 Tooltip 不攔截 Esc:位於 Dialog 等以 Esc 關閉的容器內時,同一次按鍵也會關閉該容器。

  • TagInput、Select(多選)、FileInput 的欄位內 chip 改由 Chip 呈現,三者外觀與尺寸由同一份定義維護:

    • Select 多選的 chip 會顯示該選項的 icon(先前只有單選觸發區與選單列顯示)。
    • FileInput compact 密度的 chip 內元素間距(文字與 X 之間)由 6px 改為 4px,與另兩者一致。
    • ghost 變體在唯讀或停用(沒有刪除鈕)時,chip 右內距改為與左側對稱。
    • TagInput 點 chip 的 X 不再把焦點移到該按鈕,與 Select、FileInput 一致。
    • TagInput、Select 的 badgeClassName 改經 tailwind-merge 合併,可覆寫內建的尺寸 class(FileInput 原本就是)。
  • Tiptap 套件(@tiptap/*)不再打包進框架,改由應用安裝的 peerDependencies 提供,與應用共用同一份 tiptap/ProseMirror。先前框架內含一份、應用又透過 RichTextEditor 的 extensions 傳入另一份建立的 extension,同一頁面會同時存在兩份 tiptap。新增 peerDependency @tiptap/core(^3.23.2,框架直接使用、先前 未宣告);其餘 tiptap peer 不變。

  • pdfjs-dist 不再是 peerDependency。PdfViewer 本來就把 pdfjs(含 worker)打包進框架,消費端安裝的版本從未 被使用;宣告改為與實際一致,PDF.js 的版本與 build 由框架決定。消費端若沒有直接 import pdfjs-dist,可以從 自己的 package.json 移除。

  • Button 與 ButtonLink 的 variant="link" 預設顯示底線,讓文字連結在未 hover 或聚焦時即可辨識。

  • prompt.info 預設 actions 改為空陣列;無 action 的 MessageDialog 由呈現層提供 OK。單一 Toast action 現在會顯示,不再由 × 隱式取代。

  • MessageToast 有 actions 預設持續顯示;autoHideDuration 支援 null 或依訊息回傳時間的函式。計時從呈現開始,hover/focus/視窗失焦與隱藏時暫停。

  • 確認訊息移除固定 30 秒逾時,改由選取、關閉、容器卸載、AbortSignal 或 service destroy 結束等待。操作通知不去重,純通知依 severity/thread/title/content/actions 隔離去重。

Navigation (lib/components/navigation/tabs/)

  • Tabs 以 density="tight" | "compact" | "comfortable" 取代 size,高度 32/40/48px 與 Button 的同名 density 對齊,字級一律 text-base(同 Button/Input);預設 comfortable,未指定尺寸的 Tabs 由 40px 改為 48px。移除細節見 Removed。
  • Tabs variant="boxed" 外觀改為白底 base-300 細框容器、預設貼合內容寬(w-fit, distribution="equal" 仍撐滿)、頁籤間留間距,選中頁籤以主色實心填滿、 未選中弱化文字並於 hover 顯示 base-200 底色;尺寸與圓角仍沿用 DaisyUI tabs-box。 原本灰底托白片的低對比呈現在下游從未被採用,頁面層級的視角切換反而各自手刻 tablist; 改版後可直接以 boxed 承接該情境。bordered/lifted 不受影響。

Data Display / MediaViewer (lib/components/data-display/media-viewer/)

  • MediaViewer 的內建縮圖操作對齊 MediaInput 的三級響應式排版:縮圖低於 100px 時將預覽與 單檔下載收進 More,低於 80px 時由點擊縮圖開啟操作面板;內建縮圖同步沿用 objectFit,且未指定 thumbnailGridColumns 時會從 container-based CSS grid 的實際 tracks 自動解析欄數,讓 ArrowUp/ArrowDown 與畫面一致。

  • thumbnail 模式對齊 MediaInput:每張縮圖提供彈窗預覽與單檔下載,雙擊或 Enter 也改為開啟 獨立預覽對話框,不再把縮圖預覽隱式切換成 single 模式;集合層級的 ZIP 全部下載維持不變。

  • MediaViewer 與 AuthImage 在來源缺省或空字串時改顯示中性的 ImageOff「沒有圖片」空狀態; 缺少圖片不再呈現空白框、觸發 <img src="">,也不再與真正的載入失敗共用錯誤狀態。 AuthImage.src 因此放寬為選填並接受 null。

  • AuthImage 新增可選的 objectFit,支援 contain、cover、fill、none 與 scale-down; 未提供時維持原生 <img> 行為,明確 prop 會優先於 style.objectFit。

  • AuthImage 的載入狀態不再預設回傳 null:所有 URL 類型在 HTTP 取得及圖片解碼完成前顯示 spinner,載入失敗時顯示內建錯誤狀態;既有 loading/fallback 仍可自訂,明確傳入 null 可選擇隱藏對應狀態。切換 src 時會立即回到新來源的 loading,不會短暫沿用上一張圖片。

  • PdfViewer.src 放寬為選填並接受 null,以 spinner、中性「沒有 PDF」與 error 畫面清楚區分 載入中、缺少來源與載入失敗;ReportViewerState 同步新增 idle,表達尚未產生或尚未選取報表, 且維持報表產生失敗與 ready artifact 的 PDF 載入失敗為兩個獨立層級。

  • MediaInput 恢復 thumbnailLayout="intrinsic"(restored in 19.0.0-alpha.100):thumbnail 網格可依目前 container-based 欄數、縮圖數量、capacitySlots 與新增卡片自然撐高,不再建立元件內部的垂直 捲動區;容器跨 breakpoint 時高度會隨網格列數更新。新版的 responsive action popover、全域操作 收合、拖曳把手與排序能力全部保留。完全沒有縮圖或可見管理卡片時仍維持 density 高度的上傳區, 切到 single/carousel 時亦回復 density 高度。預設仍為 constrained,既有版面不變; thumbnailContainerHeight 明確只作用於 constrained 模式。底層 MediaViewer 同步支援相同版面語意, 並公開 MediaThumbnailLayout 與 MediaInputThumbnailLayout 型別。

  • MediaInput gallery 單項操作改為三級自適應收合:一般尺寸下,每張縮圖左下內建預覽, 右上最多顯示一個自訂 action/More 與刪除,單檔下載獨立位於右下;自訂 actions 超過一個即固定收進 More。 縮圖寬度低於 100px 時隱藏左上拖曳提示,並把預覽、自訂 actions、下載與刪除全部收入 More; 低於 80px 時再隱藏 More trigger,改由點擊縮圖直接開啟同一操作面板。預覽按鈕、縮圖雙擊與 thumbnail viewport 的 Enter 改以獨立 Dialog 顯示,不受 MediaInput 尺寸限制;Escape、背景與關閉鈕 都會關閉 Dialog 並維持原本 thumbnail 模式。FileInput 與 MediaInput 共用相同預覽 Dialog。 內建縮圖按鈕與全域按鈕共用完整 24px hit area、視覺圓形、ghost 背景、陰影與 hover 效果,不再 以 content-box 額外內縮;拖曳提示也採相同的完整 24px ghost 視覺,並與刪除維持相同頂線及邊界距離。 gallery 最右上縮圖的操作組與全域右上操作組共用同一錨點及 8px 按鈕間距,自訂 action 與刪除會 逐一重疊對齊,互斥切換時不再產生位置跳動;全域操作定位維持不變。 縮圖 hover/focus 時會暫時隱藏 MediaInput 全域控制,無 hover 裝置只顯示目前選取縮圖的 actions,避免右側縮圖與全域工具列重疊。 點擊縮圖會以非循序 tabIndex="-1" 焦點鎖定其操作,不增加 Tab 次數;鎖定期間全域控制及其他 縮圖操作都不顯示,點擊另一縮圖會轉移鎖定,點擊網格空白、其他區域或元件外才解除。 指標移入全域控制原本所在位置(含定位間距)或控制項取得焦點時,全域控制會取得優先權並恢復, 同時隱藏縮圖 action、單檔下載與拖曳提示,避免其覆蓋右上縮圖時因縮圖先觸發 hover 而無法點擊, 也確保兩組控制不會同時顯示。 action 完成後 popover 會關閉並把焦點返回 trigger,但仍保留整張縮圖拖曳與 Alt + 方向鍵排序。 thumbnail viewport 高度低於 120px 時,MediaViewer 原本分散於右上、右側中央與右下的切換檢視/全螢幕/新增/下載全部控制會收進原位置的 Settings 展開鈕並向左展開;More 僅代表單一縮圖操作,修正兩種層級 trigger 無法辨識及 77px 縮圖操作重疊。 同時修正 Settings 因 focus capture 與 click 連續切換,導致滑鼠第一次點擊不會展開。 縮圖 More 與全域 Settings 的 trigger 統一為 24px、shadow-lg、300ms ease-out;More 保留省略號 與 Popover 的單檔層級語意,開啟時以相同節奏旋轉並讓浮層淡入縮放,降低兩組控制切換的視覺落差。 viewer frame 也建立明確的隔離 stacking context,確保 hover 縮圖的 transform 不會覆蓋並攔截全域控制。 renderItemActions 內的 input、select、 textarea、contenteditable 與同類 ARIA 控制項會完整保留方向鍵、Delete/Backspace 及文字輸入, 不再誤觸 gallery 排序、刪除、上傳或開啟快捷鍵;thumbnail viewport 本身的 Enter 現改由上述 MediaInput 預覽 Dialog 接手(底層 MediaViewer 的既有 Enter → single 契約不變)。 MediaViewer.onKeyDown 仍會先收到輸入控制項事件,再由 viewer 略過內建鍵盤模型;既有 props 與 action node 不需遷移。

  • MediaInput 單項操作跨 view mode 統一作用範圍:renderItemActions、下載目前檔案與刪除目前 檔案在 thumbnail 模式維持各縮圖操作;single/carousel 則集中到右上角具名的 Current item actions 群組,順序固定為自訂 action → 下載 → 刪除,切換目前檔案時同步更新 context。Viewer controls 另以 具名群組及分隔線區隔,只保留切換檢視、全螢幕、新增、下載全部與播放等 viewer/集合操作;因此 thumbnail 的 compact Settings 不再重複提供刪除目前選取。MediaViewer 新增 currentItemActionsSlot/currentItemEndActionsSlot,分別注入內建目前檔案下載之前/之後,thumbnail 不渲染這兩個 slot。

  • Data Input 錯誤訊息改為可動態公告的欄位契約:Input、Textarea、Select、TagInput、 OtpInput、Checkbox、Radio、Switch、兩種 Group、RichTextEditor、FileInput 與 MediaInput 皆以穩定 aria-errormessage 關聯一個從初次 render 起持續存在的 aria-live="polite"/aria-atomic="true" 區域;掛載後才出現或更新的驗證錯誤會被公告,清除時 節點仍保留供後續驗證重用。一般欄位由同一節點兼任視覺錯誤與 live region,避免重複文字;群組與 檔案內部驗證也只更新單一公告區。FileInput 的 button trigger 與 MediaInput 的 img/listbox viewport 不使用其 role 不支援的 aria-invalid/aria-errormessage,改以合法且支援度較廣的 aria-describedby 指向同一份可見錯誤;欄位狀態仍落在原生 file input。role="alert" 不作為一般 欄位預設,保留給必須打斷目前朗讀的緊急錯誤。另統一 error/helper message wrapper 為 <div>, 並恢復 Input/Textarea ghost error 的翻譯一致性。

  • Data Input ARIA public props 補齊:OtpInput 新增 id、aria-describedby、 aria-errormessage、aria-invalid;Select、TagInput、RichTextEditor、FileInput、MediaInput 新增 aria-errormessage。aria-errormessage 依 ARIA 1.2 維持單一 ID:元件有 error 時使用自動產生 的穩定 ID,沒有 error 時保留呼叫端值;可合併多個 ID 的描述仍使用 aria-describedby。

  • DataTable / VirtualTable 的排序狀態改為受控 / 未控雙模式。原本 sorting 無條件帶入 table state 使其恆為受控——未同時提供 sorting + onSortingChange 時排序狀態遭凍結, 點表頭毫無反應(與先前修正過的 columnVisibility 同一種缺陷)。現在未提供 sorting 即由元件 內部持有,表頭直接可用;onSortingChange 在兩種模式下都會呼叫,且一律收到解開後的具體值 而非 TanStack 的 updater 函式(未控時呼叫端手上沒有前一份狀態,無從解開)。只傳 onSortingChange 不傳 sorting 正是後端排序的典型用法:元件持有狀態、呼叫端據以重新取資料。 兩支共用新的內部 hook useTableSorting,行為由建構保證一致。

  • DataTable / VirtualTable 的表頭全選鈕改為三態,checked 只代表「整個資料集都被選取」。 VirtualTable 在 infiniteScroll.hasNextPage 為 true 時、DataTable 在 totalPages > 1 時, 全選鈕封頂在 indeterminate——該情境下全選的範圍只到已載入的列/當前頁,宣稱 checked 是不成立的 斷言。資料載完(或只有一頁)後才會達到 checked。連帶 aria-label 據實改為 Select all loaded rows / Select all rows on this page。

  • onDeselectAll 交出的函數改為清空整份 rowSelection(DataTable / VirtualTable)。原本用 toggleAllRowsSelected(false),它只 delete 當前 row model 內的 id,其他頁/已離開資料集的殘留 key 會存活下來,使「取消全選」無法真正清空。改用 resetRowSelection(true)。

  • onSelectAll / onDeselectAll 交出的函數改為穩定識別(DataTable / VirtualTable)。原本每次 effect 都交出新函數,配合 README 文件化的 onSelectAll={(fn) => setSelectAll(() => fn)}(inline 箭頭)會形成「交出新函數 → 呼叫端 setState → 重新 render → 再交出新函數」的無窮迴圈。見 Fixed。

  • DataTable / VirtualTable / VirtualList 非受控 selection 在 data 變動時自行收拾殘留。 受控模式不介入(消失的 id 是「被篩掉」還是「已刪除」只有應用層知道);非受控模式下組件是唯一 持有者:有 getRowId 則修剪(保留仍在資料集中的 key),無 getRowId 則因 id 即索引而清空 (VirtualTable / VirtualList 的純追加、DataTable 的純換頁除外——那些情境 id 仍穩定)。 元件內排序不算資料變動——clientSorting 為真時排序不換 data、row id 也完全穩定, 故排除在偵測之外(否則 DataTable 會在點表頭時清掉未控選取,與 VirtualTable 行為分歧)。

  • Select 只在選單開啟時消費 Escape——搜尋框與已開啟的 combobox 會以 preventDefault() + stopPropagation() 關閉自身選單,不連帶關閉外層 Popover/Modal;選單已關閉時則不再無條件 preventDefault(),因此焦點位於 Select 時按 Escape 會正常交給外層 Modal 關閉。

  • 日期時間 picker 統一以必填 type 宣告語意型別——DatePicker 支援 instant/local-date, DateTimePicker 支援 instant/local-date-time;Instant 模式的值為 Date,可指定 IANA 時區, 預設瀏覽器時區。時區控制移至月曆標題的上一月按鈕與月份標題之間,顯示 location 短名,tooltip 顯示完整 zone ID;切換時區保留牆鐘、重算 Instant。DST 重疊時間預設拒絕,可用 disambiguation="earlier" | "later" 明確決定;不存在的牆鐘時間一律拒絕。 DatePicker type="instant" 的 instantTime 預設為 start-of-day;改用 end-of-day 或自訂鐘面時間, 必須由人類指定,或由 AI 建議並經人類確認。合法字面值在編譯期精確檢查,動態非法字串則在執行期 以欄位錯誤呈現且不退回預設值。選日會依有效 instantTime 重建 Instant;DateTimePicker 第一次 編輯後以 withSeconds 宣告的分鐘/秒精度重建 Instant,不保留毫秒。

  • HTTP client 的 request body 與 query params 統一遞迴編碼日期——Date 使用 toISOString() 產生 canonical UTC Z 格式並保留毫秒;LocalDate/LocalTime/LocalDateTime 字串 原樣傳送,不附加或推測時區。無效 Date 會在送出前拋出 RangeError。

  • Button variant="soft" 維持下游可客製的靜態透明契約——19.0.0-alpha.71 曾改用 DaisyUI btn-soft 加入常態淡底,會改變既有下游畫面的視覺階層。現恢復以 btn-ghost 為基底:平時無背景、只保留語義文字色,hover / focus 時才填色;下游可透過 className 依所在版面定義淡底、邊界或其他靜態識別。ghost 則維持連互動填色也不內建的客製入口。

  • PdfViewer 預設改為適頁寬度(fitMode='width')——先前預設 scale=1(PDF 原始尺寸)。 升級後同一份 PDF 在既有畫面會以貼合容器寬度呈現、不再水平捲動;縮放(+ / - / 工具列)語意 由「絕對縮放」變成「相對 fit 基準的倍率」(範圍 0.5x–3x 不變)。要保留舊行為傳 fitMode="none"。

  • MediaViewer thumbnail 模式的「選取」與「開啟」分離——先前單擊縮圖會同時選取並跳進 single 模式,導致滑鼠使用者無法在縮圖模式下移動選取(一點就離開),「當前選取」對滑鼠形同唯讀。 現在:單擊 / 方向鍵只改選取,雙擊 / Enter 才進 single 模式。

    • renderThumbnail 的 helpers 中,onSelect 語意隨之改為「只改選取」,新增 onOpen (選取 + 進 single 模式)。沿用舊行為的 slot 實作需把 onSelect 換成 onOpen。
    • 連帶:MediaInput 工具列的刪除鈕不再限定 single 模式,thumbnail 模式同樣渲染並作用於 當前選取的縮圖(與 Delete 鍵同義)——先前該模式下只有 hover 才出現的每張縮圖 X, 工具列沒有任何 Tab 到得了的刪除入口。
  • MediaViewer thumbnail 模式的 viewport 語意由 role="img" 改為 role="listbox"(single / carousel 模式不變),並帶 aria-activedescendant 指向當前縮圖;縮圖為 role="option"、中介容器為 role="presentation"。以 getByRole('img') 取得 thumbnail 模式 viewport 的測試需改為 listbox。

  • MediaViewer 的控制項在觸控裝置改為恆顯示——先前僅在 hover 時顯示,觸控裝置沒有 hover 事件、等於永遠不顯示。判準為 (hover: hover) media query;有 hover 能力的裝置行為不變。 同步套用於其 PDF 單檔檢視的頁碼列。

  • createHttpClient() 新增 mswHealth 配置:MSW 啟用時,所有 API 請求在送出前先由協調器確認攔截 健康;GET 等讀取共用 1 秒健康快取,POST / PUT / PATCH / DELETE 不沿用健康快取。自癒仍失敗時在 request 階段以 503 ErrorResponse 擋下,不讓讀取或寫入穿透真實後端;成功但回 text/html 的 SPA fallback 亦轉為相同錯誤並發布給守衛(明確要求 blob/text 等非 JSON 回應時不套用此判準)。 健康結果快取 1 秒,失效結果短暫快取 500ms,避免 retry 造成重複探測與自癒。synthetic 錯誤的 status 固定為 503,原始探測 HTTP status 保留於 params 診斷欄位,不再把穿透回應的 200 誤當成錯誤本身的 HTTP 語意。

  • FileInput 的下載(「下載全部」與單檔下載)現在於欄位下方顯示進度/結果狀態,並在部分失敗時中止——單檔仍下載原檔名、多檔仍打包成 zip(downloadAllFileName),行為維度不變,變的是回饋與錯誤語意:

    • 內嵌狀態列(非對話框):呈現於欄位下方 helperText 位置。取得/打包中顯示細進度條(「取得 N/M → 打包中」);成功顯示「下載完成」、失敗顯示後端訊息,訊息後自動消失(錯誤停留較久)。無關閉/重試鈕——要重試直接再按 FileInput 的下載鈕。進度條延遲顯示:本地 File / FileProxy 幾乎瞬間完成、不閃進度條,直接顯示完成訊息。
    • 不再靜默略過失敗:先前預設路徑取檔失敗只 console.error + 略過,使用者拿到殘缺甚至空的 zip 卻毫不知情。現在任一檔取得失敗即中止,狀態列顯示失敗檔名 + 後端 RFC 7807 訊息(經 getApiErrorMessage),不產生誤導的殘缺 zip。單檔網路下載失敗同樣顯示(先前亦為靜默 console.error)。
    • 相容性:傳入自訂 onDownloadAll 者不受影響(仍由呼叫端自理 UX);showDownloadAll / downloadAllFileName 等 API 不變。下游會觀察到的是「下載時欄位下方多了一行狀態訊息」這個執行期行為。
  • DataTable / VirtualTable 選中列新增左緣 3px 強調條(與 VirtualList master-detail 的選中標示一致):原本選中列只有柔和 primary 底色(bg-primary/10),現在加上左緣強調條,選中更明顯。強調條隨 color prop 對應 8 色。實作為 inset box-shadow 套在該列第一個 cell(<tr> 上的 box-shadow / border-left 在 DaisyUI table 不渲染),不推擠版面、與 sticky 欄相容。純視覺增強,無 API 變更。

  • VirtualTable / VirtualList 的 showScrollToTop 預設由 false 改為 true——虛擬化元件本就為長清單/無限捲動而生,回到頂部是該場景最標準的 affordance,預設關閉不符直覺。按鈕自我隱藏(僅捲離頂部超過 scrollToTopThreshold(預設 400px)且 hover 時才現、觸控裝置恆現),故短清單 / 停在頂部時開著也不打擾。需關閉(如與應用層右下角 FAB 撞位)傳 showScrollToTop={false}。

  • DataTable / VirtualTable 修正 sticky 欄(選取 / 序號欄)的列底色接縫——選取 tint 與 hover 底色皆與其他欄一致。DaisyUI table-pin-cols 給 sticky <th> 不透明 bg-base-100(供水平捲動不透光),會蓋住套在 <tr> 上的半透明 tint(bg-primary/10)與 DaisyUI row-hover 的底色,使 sticky 欄比其他欄淺、出現接縫。修正:sticky 欄的 cell 依狀態改套不透明等價色——選中列用 color-mix 算出的 bg-primary/10 over base-100 實色;未選中列 hover 時補 group-hover:bg-base-300(對齊 row-hover)。四種狀態(未選中預設 / hover、選中預設 / hover)各欄底色皆一致。純視覺修正,無 API 變更。

  • DataTable 的分頁 UI 由右側垂直「位置指示條」改為表格下方橫向「分頁器」(整組靠右:每頁筆數下拉 + 範圍/總數 + 首/上/下/末頁),版面與字級(1rem)對齊既有 MUI TablePagination 慣例,便於與舊系統畫面並存。連帶:每頁筆數選擇由齒輪移入分頁器;齒輪首個動作因此改為重設(版面歸位),與 VirtualTable 一致。API 相容(pagination / onPageChange / onPageSizeChange / pageSizeOptions 不變),僅 UI 位置與外觀改變。

    • 導覽為 « ‹ › » 四顆圓形 ghost 鈕,無數字頁碼:中間頁需逐頁翻,但末頁一鍵可達(首/末頁鍵補足無頁碼的跳頁缺口,且導覽寬度不隨總頁數增長)。
    • 分頁器恆顯示:總頁數為 1(或無資料)時整組仍在、四顆導覽鍵皆 disabled——避免分頁器隨資料量出現/消失造成表格底部跳動。
    • 新增 showPagination?: boolean(預設 true):設 false 時不畫內建分頁器,但 pagination 資料仍驅動序號偏移與骨架列數——讓應用層改用自己的分頁器(因 pagination 不只畫 UI,故「不傳 pagination」不是等價替代)。
    • 分頁器的按鈕改用框架 Button 元件(原為原生 daisyUI class),與框架其餘元件一致:導覽為 variant="ghost" shape="circle" density="tight"、每頁筆數 trigger 為 variant="ghost" density="tight"。兩者皆以 className 覆寫 Button ghost 變體預設的扁平 hover(icon-only 圓鈕無底色就看不出可點、圓形亦不可見)與 density="tight" 的 text-sm(分頁器整體字級為 1rem)。
    • i18n:新增 First page / Last page / Per page 三個 key。既有下游若沿用自己的 nls 副本,需補上這三個 key(未補會退回顯示英文 key);不再使用 Page ${page}(數字頁碼的 aria-label)。
  • VirtualTable 的預設 dateFormat 由 yyyy-MM-dd 改為 yyyy-MM-dd HH:mm(與 DataTable 一致)。兩個表格原本各持一份 default-cell-formatter,DataTable 早已在一次更新中把預設改為含時分並附上推理(只有帶時區的 Instant 會被 apiClient 轉成 Date 而走到預設格式化,多為建立 / 修改時間戳,通常要看到幾點幾分;LocalDate / LocalDateTime 保持字串、走 string 分支不受影響),而 VirtualTable 那份 fork 停在舊值、漏接此更新。現在兩者共用單一實作(shared/default-cell-formatter),此差異一併收斂。影響:VirtualTable 中未指定 cell、值為 Date 的欄位,預設會多顯示 HH:mm。需只顯示日期的欄位覆寫 defaultCellFormatter={{ dateFormat: 'yyyy-MM-dd' }}。(實務上接後端時,LocalDate 欄位是字串、原樣顯示日期,不受影響。)

  • DatePicker / DateTimePicker / TimePicker / InstantPicker 的欄位改為可鍵盤輸入的分段遮罩(不再 readOnly)

    • 之前:欄位是 readOnly 的唯讀顯示,值只能經 popover 點選。這比它們取代的原生 <input type="date"> / type="datetime-local" 少了一整種輸入方式——大量鍵入日期時 每一筆都得開月曆點三下,極不友善
    • 現在:欄位本身是 MUI 風格的分段遮罩編輯器——↑↓ 增減當前段、←→ 切換段、 打數字自動跳段、Backspace 清當前段、Home/End 跳首末段,並可貼上 (2026-07-16 / 2026/7/16 / 20260716 皆可寬容解析)。分/秒的 ↑↓ 步進沿用 既有的 minuteStep / secondStep
    • 顯示 vs 編輯兩態:未聚焦顯示 formatValue 的格式化結果(預設含 (週X) 後綴), 聚焦時切為固定的遮罩形狀(YYYY/MM/DD、HH:mm[:ss]、YYYY/MM/DD HH:mm[:ss])。 formatValue 因此只影響顯示態、不需可逆
    • 可選性只約束月曆、不約束鍵盤:days / disabledDate 標記為不可選的日期在月曆上 仍點不到,但可以鍵入且 onChange 照常送出(status 一併帶出可選性)。打字途中 攔截會在使用者尚未打完時就搶著改值;可選性由表單驗證與後端 validate() 裁決
    • Impact(行為變更,非編譯錯誤——props 與值形狀完全不變):
      • 點擊欄位不再開啟 popover,改為定位編輯分段;popover 由日曆/時鐘鈕或 Alt+↓ 開啟(ARIA combobox 慣例)。↑↓ 與 Enter/Space 不再開啟 popover—— ↑↓ 已用於增減分段
      • 依賴「點欄位即開月曆」或「Enter 開月曆」的 E2E 測試需改為點日曆鈕或送 Alt+↓
      • 欄位 readOnly 屬性由 true 變 false;斷言 input.readOnly === true 的測試需更新 (傳入 readOnly prop 仍照常停用編輯)
    • 已知限制:遮罩以 keydown 攔截數字鍵,故不支援 IME 組字輸入;Android 部分虛擬鍵盤 不送出可靠的 key 值——該情境仍可用 popover 選取或貼上
  • AuthImage 認證圖片的 URL 組合改為直接透傳 src 給 httpClient(對齊 MediaViewer 做法)

    • 之前:載入前先經內部 normalizeUrlForHttpClient(src),以硬編的 baseURL = '/api/v1/' 假設剝掉 /api/v1 前綴,再交給 axios 用 baseURL 組回
    • 問題:實際 apiClient.defaults.baseURL 是各部署的 context-path(經 environ.baseURL 注入),並非 /api/v1/;在 baseURL 非 /api/v1/ 的部署,剝前綴會遺失 /api/v1 段而組出錯誤路徑。MediaViewer 以同一 httpClient、同樣的 /api/v1/... 路徑直接透傳且運作正常,證明前綴應由 baseURL 組合、不該預剝
    • 現在:AuthImage 與 MediaViewer 一致,直接把原始 src 交給 httpClient.get(src, { responseType: 'blob' }),URL 前綴一律由 httpClient 的 baseURL 負責
    • Impact:src 應傳與 MediaViewer(FileDescriptor.url)相同形式的路徑(如 /api/v1/files/...)。若既有消費端在 baseURL 確為 /api/v1/ 的環境下依賴舊的剝前綴行為,升級後該圖片請求會少一層前綴——請改讓 baseURL 承載 context-path、src 保留完整 /api/v1/... 路徑
  • isExternalUrl(url, apiBase?) / needsHttpClientFetch(url, apiBase?)(@appfuse/appfuse-web/utils)新增可選 apiBase 參數,並改以 API 伺服器來源判定「外部」(安全邊界修正)

    • 背景:這兩個判斷決定圖片/檔案「附 token 走 httpClient」還是「直接 <img> / 原生 fetch」。因為一般請求攔截器不對絕對 URL 做守衛(不像上傳 presigned 路徑),這道判斷是防止 Bearer token 洩漏給第三方的唯一防線
    • 之前:isExternalUrl 以 window.location.origin(頁面來源) 判定同源。僅在「前端與 API 同源」時才等價於 API 來源;跨源 API 部署(baseURL 為 https://api.example.com/...)下,指向 API 的絕對檔案 URL 會被誤判為「外部」→ 不附 token → 401、圖載不出來
    • 現在:以 apiBase(httpClient 的 baseURL)的 origin 判定。相對路徑一律非外部(必接 baseURL 打 API 伺服器);絕對 URL 才比對 origin:等於 API origin → 附 token,不等於 → 外部、不附 token。apiBase 省略時預設 window.location.origin(向後相容,行為與舊版一致)
    • 框架內 AuthImage / MediaViewer / PdfViewer / FileInput 已改傳 httpClient.defaults.baseURL,跨源 API 部署下正確載入認證圖片/檔案;直接呼叫此二 util 的下游若在跨源 API 部署,應一併傳入 httpClient.defaults.baseURL
  • Dialog 動作按鈕密度由 compact 改為 comfortable(40px/14px → 48px/16px):對齊框架其餘元件(Button / 表單組件預設即 comfortable)與對話框內文字級(text-base 16px),使阻塞式對話框的主互動——動作按鈕——成為明確的決策焦點(先前按鈕字級 14px 反小於內文 16px)。不另開尺寸 prop:阻塞式對話框宜全 app 一致,極少數需異尺寸的消費端可用公開的 prompt service 自建對話框訂閱者

    • 動作按鈕加入主次階層:actions 的順序即主次——第一項為主要(肯定選項)、其餘為次要,次要一律比主要「降一階」(solid → outline、outline → ghost),三種 dialog variant 下皆有對比。零 API 變更(actions 仍為 string[])。
      • 此慣例非新增、只是把既有語意畫出來:MessageDialog 的關閉 / ESC 早已 resolve 最後一項作為否定選項,且 prompt.confirm / prompt.warn 的用例一律是 ['刪除', '取消'] 這種順序——先前所有按鈕同色同樣式,使「哪個是安全選項」完全不可見(破壞性確認尤其危險)。
      • 影響:既有 dialog 的外觀會變(第二顆以後的按鈕改為較弱樣式),行為與 API 不變。若某處刻意把取消放在第一項,主次會顛倒——但那本就與 ESC 慣例相衝,應調整順序。單一 action(如 ['OK'])外觀不變。
      • 次要為 ghost 時補回 hover / focus 底色(Button 的 ghost 變體預設扁平),避免對話框的次要鍵弱到不像可點。
      • 新增 DIALOG_SECONDARY_BUTTON_VARIANT config map(未列入公開 export,與既有 DIALOG_BUTTON_VARIANT 同層)。
    • 動作按鈕間距同步由 gap-2(8px)調為 gap-3(12px):按鈕升到 48px 高後,8px 未跟著調——同 severity 的多顆實心按鈕會黏成一塊,中間的縫看似按鈕內部分隔線而非兩顆獨立按鈕(「刪除 / 取消」這類破壞性確認尤其危險)。12px 低於容器 padding(16px)一階,分得開又不散。純視覺調整,無 API 變更
  • Select 下拉的內建文案 No options found / Create "…" / 新增的 Searching... 改為經 t() 翻譯(先前 No options found 寫死未 i18n)

  • DataTable 預設 cell 格式化的 dateFormat 由 'yyyy-MM-dd' 改為 'yyyy-MM-dd HH:mm'(defaultCellFormatter)

    • 動機:Date 值只有在欄位為後端 Instant 時才走此格式化路徑(apiClient 唯一會轉 Date 的日期型別;LocalDate / LocalDateTime 保持字串、走 string 分支原樣顯示),而 Instant 多為建立 / 修改時間戳,預設丟掉時分不合直覺
    • 下游若依賴舊的「只顯示日期」,於該欄 defaultCellFormatter={{ dateFormat: 'yyyy-MM-dd' }} 覆寫
  • time.toLocaleString / toLocaleDateString / toLocaleTimeString 新增可選第二參數 options?: Intl.DateTimeFormatOptions(透傳原生 toLocale*),可指定顯示時區(如 { timeZone: 'Asia/Taipei' })等——供多租戶等需固定顯示時區的 Instant 呈現。預設行為不變(無 options 時仍用執行環境時區),純增量、向後相容

  • Data Input 浮動 label 採 MUI outlined 機制,不再預留版面高度(Input / Textarea / Select / TagInput / FileInput / MediaInput / RichTextEditor,及 CheckboxGroup / RadioGroup 的 inline 模式)

    • 之前:bordered 浮動 label 元件在 wrapper 預留 pt-3(約 12px label band),佔據版面高度
    • 現在:浮起的 label 改以 absolute 溢出至 input 上方、不佔版面高度(對齊 MUI outlined),label 空間靠版面 row gap 提供;CheckboxGroup / RadioGroup inline 同步移除預留的 pt-3,min-h 對齊輸入框高度(compact 40 / comfortable 48)
    • 效益:有 label 與無 label 的 input 同高,並排時可對齊
    • 視覺影響(下游升級需注意):label 溢出改由表單 row gap 吸收,原本靠緊湊 space-y-2 排列 bordered 欄位的表單會變擠(label 撞上一列)。下游需逐表單放寬 space-y(建議 ≥ space-y-5/space-y-6)、移除任何手動 pt-3 band 補償、並把 inline checkbox/radio/switch 的對齊外殼改用 container items-center
    • 例外:RichTextEditor 的 underlined(MUI Standard)維持原行為
  • RichTextEditor 底層編輯引擎由 SlateJS 改為 Tiptap v3 (ProseMirror)

    • 功能 1:1 對等遷移:marks、heading、list、blockquote、code-block、color、highlight、sup/sub、align、link、image (含 resize 面板)、table (含 merge/split) 全數保留
    • 鍵盤可達性與 i18n 行為不變:Alt+F10 進入工具欄、roving tabindex、Home/End/Esc 導航、所有 toolbar 標籤與 placeholder 透過 t() 翻譯
    • Variants / density / color / floating label / notch 行為不變
    • 理由:Slate 0.x 維護節奏慢、API 不穩定;Tiptap 生態系(extensions、collaboration、AI)更活躍,社群與文件遠勝 Slate
  • Data Input 元件三變體架構統一對齊 MUI(Input / Textarea / Select / TagInput)

    • bordered 對齊 MUI outlined:notch(fieldset + legend)邊框缺口、padding-x 14px、label start 對齊;fieldset notch 透過 px-3 / focus px-[11px] 對齊 MUI
    • underlined 對齊 MUI standard:container mt-4 推下、label 浮在 container 頂部、focus 底線中心展開動畫
    • ghost 為 inline 編輯設計:字級行高繼承父容器([font-size:inherit]! leading-tight)、px-[0.5em] 隨字級縮放、wrapper mx-[0.25em] pb-1 保留呼吸與底線距離
    • compact 字級改為 text-[0.9625rem] pointer-coarse:text-base(桌面 15.4px / 觸控 16px,避開 iOS auto-zoom)
    • 配套元件:新增 Tooltip(lib/components/overlays/tooltip/)— cloneElement 注入 handlers 不增加 DOM wrapper,ghost variant 的 helperText / error 透過 Tooltip 顯示不打斷文字流
    • 配套 daisyUI:fusion theme --radius-field 由 0.5rem → 0.25rem(對齊 MUI Material Design)
  • Chip / Badge 樣式對齊 MUI Chip(TagInput / Select multiple 使用)

    • 改用 daisyUI badge-soft 變體(淺色背景 + 深色文字),視覺輕量、不在輸入框內顯得擁擠
    • 形狀 rounded-full 膠囊形、comfortable 高度 32px(對齊 MUI medium)
    • close button 22×22 + bg-current/15 半透明圓背景(對齊 MUI Cancel icon)
    • 字級 13px、gap-x-1.5 (6px)、gap-y-1 (4px),與 MUI 視覺一致
    • wrap+bordered 場景:container padding-left 縮為 2px、第一行第一個 chip 文字對齊浮動 label 視覺 column;pt-2 pb-1.5 不對稱 padding 讓 wrapper 高度與 Input 對齊
  • Data Input compact 字級與 chip 結構二次校準(supersedes 上方「Data Input 元件三變體架構」與「Chip / Badge 樣式」中相關細節)

    • 七個元件(Input / Textarea / Select / TagInput / FileInput / MediaInput / RichTextEditor)compact floating label 字級統一為 text-base (16px),與 comfortable 一致;對應 notch legend 字級統一為 text-[0.78rem]
    • 理由:原 text-[0.9625rem] (15.4px) 造成 compact 浮起 label 偏小、與 comfortable 風格不一致;pointer-coarse:text-base 旁路在桌面也已 16px 後屬冗餘
    • 配套:compact bordered 浮起 label transform 對齊 comfortable(top-1 -translate-y-4),消除字級變大後的 2px 偏下視覺
    • Chip(TagInput / Select / FileInput)對齊 MUI Chip 最終規格:
      • 移除 daisyUI .badge 預設 1px border
      • 對稱 px-3 padding(comfortable)/ px-2(compact),唯讀 chip 文字置中(修正 pl-3 pr-[5px] 造成的文字偏右)
      • gap-1.5 對齊 MUI text-to-X 視覺間距
      • X 按鈕重構為雙層結構:外層 size-[22px](compact size-4)透明 hit area,內層 size-[18px](compact size-[14px])可見實心圓 bg-current/15;對齊 MUI deleteIcon SVG 框與可見圓直徑,同時擴大 click target 改善 accessibility
      • -mr-[7px](compact -mr-[3px])負 margin 把 X 按鈕拉回距 chip 右邊 5px,對齊 MUI deleteIcon 位置
    • TagInput / Select underlined 容器加 pt-1 形成對稱 padding,文字中心對齊 Input 元素中點(修正 2.5px 偏上;瀏覽器對單行 input 文字置中時會忽略 asymmetric padding)
    • Select / TagInput / FileInput bordered notch 與 FileInput underlined 補 shadow-none focus:shadow-none focus-within:shadow-none,蓋掉 daisyUI .input 預設 inset box-shadow(bordered 模式會穿過 floating label 形成淡橫線、underlined 模式會出現在 label 下方)
    • 故事新增:TagInput.MuiBenchmark 加入「暱稱 / 興趣分類」對照列,驗證未浮起 label 與 Input 文字內容垂直對齊
  • Select ghost variant 清除互動重新設計

    • 移除容器外 X clear 按鈕(視覺更輕量)
    • single 模式 dropdown 頂部加「—」破折號選項作為清除入口(符合下拉選單「空白選項」UX 慣例)
    • multiple 模式仍可透過個別 chip X 移除單個 tag
  • Stories 升級(全 Data Input)

    • Variants 改為 3×2 矩陣(取代原獨立 Variants + Densities),一覽變體 × 密度排列組合
    • 新增 MuiBenchmark story:與 MUI 對應元件(TextField / Autocomplete)並列校準
    • 新增 Select.TableEdit / TagInput.TableEdit story:展示 ghost 在表格 cell 內聯編輯
    • InlineEdit 加入 multiple Select / Input + TagInput 並排示範
  • Input handleClear:onClear 提供時優先呼叫,不再重複觸發 onChange(對齊 JSDoc 與既有測試「onClear 優先於 onChange」)

  • Radio 對齊 MUI 視覺語意(仿 commit 971efd2 的 Checkbox 對齊)

    • 色階分層仿 MUI FormLabel vs FormControlLabel 語意:RadioGroup label 用 text-base-content/60(欄位名稱色,同 Input 浮動 label);個別 Radio label 與未選邊框用 text-base-content(欄位內容色,同 Input 文字);error 才轉紅
    • 個別 Radio label 不再隨 color prop 變色(之前 color="primary" 會讓 label 跟著變 text-primary),對齊 MUI FormControlLabel.label 不隨 Radio color 變色行為
    • gap-3 → gap-1.5(radio↔label 間距,對齊 MUI)
    • RadioGroup 群組錯誤訊息與輔助文字統一用 mt-2(不再依 density 調整 helperMargin),與 CheckboxGroup 一致
  • Switch 對齊 MUI 視覺語意(仿 commit 971efd2 / 402595b 的 Checkbox / Radio 對齊)

    • 個別 Switch label 不再隨 color prop 變色(之前 color="primary" 會讓 label 跟著變 text-primary),永遠使用 text-base-content,僅 error 轉 text-error;對齊 MUI FormControlLabel.label 不隨 Switch color 變色行為

    • gap-3 → gap-1.5(toggle↔label 間距,對齊 Checkbox/Radio 與 MUI)

    • helperMargin 對應 gap-1.5 重算:compact ml-[calc(26px+6px)]、comfortable ml-[calc(33px+6px)]

    • 之前:compact 與 comfortable 都使用 text-base (16px)

    • 現在:compact 使用 text-sm (14px),comfortable 使用 text-base (16px)

    • 理由:與所有 Data Input 組件保持一致的密度語義

    • 視覺影響:字體稍微縮小,可透過調整根字體大小全局放大

    • 詳見:lib/components/data-display/data-table/README.md 的 Density 章節

  • Chip 預設改為輪流換色(FileInput / Select / TagInput)

    • 當 color 未指定(default)時,chip 依索引輪流套用一組中性語意色(primary/secondary/accent/info/success),提升多 chip 的可辨識度
    • 指定 color(如 primary)或欄位處於 error 狀態時,所有 chip 仍統一單色,維持語意一致
    • 視覺變更:升級後預設的多選 chip 不再全為灰色 neutral,而呈現輪流色彩;如需維持單色,明確指定 color
    • 共用邏輯抽到 lib/components/data-input/chip-color.ts(resolveChipColor、CHIP_ROTATION_COLORS)
  • FileInput chip Popover 內容擴充 + 氣泡外觀

    • Popover 上半新增檔案資訊區(檔名、類型、大小);外框與氣泡尾巴呼應該 chip 顏色
    • 新增 i18n key:Type、Size(下游專案需在 nls/term 補上翻譯)
  • Dropdown.Content 新增 arrow prop(預設 false,不影響既有 dropdown)

    • 開啟後外框呈現對話氣泡外觀,氣泡尾巴指向 trigger(position 決定朝上/朝下、align 決定水平位置)
    • 尾巴顏色透過 border-inherit 跟隨外框邊框色
  • FileInput 新增上傳標誌鈕(bordered / underlined variant)

    • 右側顯示 Upload 圖示鈕作為「這是檔案欄位」的視覺標誌(類似 Select 的 chevron),解決無值時看不出是 FileInput 的問題
    • 永遠顯示,readOnly / disabled 時隱藏;ghost(inline edit)不顯示以維持融入文字流
    • 與「下載全部」鈕並排於右側按鈕群組,chip 容器依鈕數動態預留右側空間
    • 視覺變更:升級後 bordered / underlined 的 FileInput 右側會多一個上傳圖示鈕
    • 新增 i18n key:Upload files(下游專案需在 nls/term 補上翻譯)
  • Dialog 動作按鈕的主次階層改以「填充 + 色相」雙軸降階,並改為次要在左、主要在右

    • 次要鍵改中性色:原本主次只降填充(solid → outline)、次要沿用 severity 色, 於是 error / warning 對話框會出現「紅底刪除 + 紅框紅字取消」兩個同色鈕互相競爭。 取消是安全的逃生口,語意上不該漆成危險色。現在次要一律 color="neutral" + variant="outline", severity 色只保留給主要動作
    • 渲染順序反轉:由「主要在左」改為 [次要…] [主要](主要鍵置於最右)。 宣告面不變——actions 仍是「主要寫在前」(actions[0] 為主要、MessageDialog 的 關閉 / ESC 仍 resolve 最後一項為否定選項),只有畫面左右順序改變,故無需改 call site。 此舉使 Dialog 與應用層既有的自訂對話框(一律次要在左)一致
    • density 由 comfortable(48px)改為 compact(40px)
    • severity="info" 改用 info 色(標題列 bg-base-200 → bg-info、按鈕 neutral → info)。 此前五個 severity 中唯獨 Info 不使用自己的顏色而走中性灰,且與兩個孿生元件相衝—— Toast 與 Alert 皆將 Info 對應到 alert-info。該特例無註解說明、亦無設計記錄, 判定為遺留而非設計。現五個 severity 一律「標題列色 = 按鈕色 = severity 色」
    • 視覺變更:升級後所有經 Dialog / MessageDialog / prompt.confirm / prompt.error 顯示的對話框,其動作鍵的顏色、左右順序與高度皆會改變。無 API 變更
  • Dialog / Toast / Alert / Modal 的字級與字重統一為 text-base + font-normal

    • 此前四者各行其是:Dialog 標題 text-lg font-normal、Modal 標題 text-lg font-semibold、 Toast / Alert 標題 font-medium;且 Alert 在有標題時把內文降為 text-sm opacity-90, Toast 卻不降——同一組訊息面元件出現四種排版處理
    • 層級改由顏色與版面承擔(severity 標題列、邊框、位置、圖示),不由字級字重承擔。 設計語言因此更單純,下游 design-system 也更容易對齊
    • Alert / Toast 的容器另加顯式 text-base——daisyUI 的 .alert 自帶 font-size: .875rem, 未顯式覆寫時標題與內文會繼承成 14px 而非 16px
    • 動作鍵的字級隨 Button 的 density="compact" 一併改為 16px(見下條)
    • 視覺變更:升級後對話框與訊息面的標題不再放大或加粗;Alert 帶標題時內文不再縮小。無 API 變更
  • DataTable / VirtualTable 的 density="compact" 字級由 text-sm(14px)改為 15px (text-[0.9375rem]/[1.375rem],行高 22px);表頭改為元件自帶樣式,不再依賴應用層 CSS 覆寫

    • 表格的 density 刻意包含字級,與 Input / Button(density 只管高度)不同——這是有意識的 差異:表格 compact 的目的是一屏塞更多列,字級是達成手段;按鈕 compact 只是佔位小,縮字 無功能意義。原本 table.config.ts 寫「與所有 Data Input 組件統一」,但 Input 各密度一律 text-base、從未使用 text-sm,該敘述與事實不符,已更正
    • 取 15px 而非沿用 14px:介於 text-sm(14/20) 與 text-base(16/24) 之間,既保留密集感, 又不與其餘元件(一律 16px)落差過大
    • thead 新增 text-base-content:daisyUI 對 :where(thead,tfoot) 直接套 60% 淡化色, 元件過去靠應用層 CSS 覆寫補回,現由元件自己負責
    • 8 處重複的密度字級三元式收斂為單一常數 TABLE_DENSITY_FONT_CLASSES(table.config.ts)
    • 表頭底色由 bg-base-100 改為 bg-base-300:字級 / 字重 / 文字色與表身一致後,表頭的 識別度全靠表面色,而原本的 bg-base-100 與表身同色、等於沒有區隔。不可用 bg-base-200—— table-zebra 的斑馬列正是該色(實測色值完全相同),表頭會被誤讀成另一條斑馬紋
    • ⚠️ 下游若在 tailwind.css 以 @layer daisyui 覆寫 .table 的字級/字重/顏色,請移除—— 該覆寫會蓋掉元件的 density 字級,使 compact 與 comfortable 渲染完全相同(此坑在框架自身 與參考實作中都曾實際發生,本版一併清除)。移除後表頭顏色由元件補上,不會變淡
  • Button 的 density 不再改變字級——tight / compact / comfortable 一律 text-base(16px), density 只管高度(32 / 40 / 48px),對齊 Input

    • Input 各密度一律 text-base(config 內 10 處無一例外),僅高度不同;Button 原本 tight / compact 為 14px,同高度並排時字級卻與 Input 不一致
    • button.config.ts 原本的註解 // 14px - matches Input compact font size 與事實不符 (Input 從未使用 text-sm),一併更正
    • 框架內部原本就有兩處在對抗此設定:DataTable 分頁器與參考實作的 product-detail 皆以 className="text-base" 覆寫 tight 的 text-sm;本次一併移除該覆寫,行為不變
    • 影響:所有 density="tight" / "compact" 的 Button(含 Dialog / MessageDialog 的動作鍵、 各元件的關閉鈕、表格內操作鈕)字級變大 2px;按鈕高度不變(btn-sm / btn-md 固定), 實測文字未溢出(tight 內容高 30px / 可用 30px)
  • Dialog / Modal 的 Storybook Docs 頁改為 iframe 渲染 story(docs.story.inline: false)

    • 兩者為 portal modal(position: fixed),Docs 頁原本把所有 story inline 渲染在同一個文件流裡, 導致每個 open 的對話框蓋滿整頁、背景幕層層相疊,Docs 完全無法閱讀
    • 改為 iframe 後 fixed 定位被限縮在各自的 iframe 內;DesignLanguage 另行加高
    • 僅影響 Storybook 文件呈現,不影響元件行為

Deprecated​

  • usePromptSubscription() — 改用 usePromptQueue()。前者只回傳訊息陣列、沒有移除能力, 不足以實作任何真正的容器(框架自己的 MessageDialog / MessageToast 因此曾各自 inline 一份訂閱邏輯,同一段邏輯在框架裡有三份)。現為 usePromptQueue 的薄包裝,行為不變。

Removed​

  • Badge 的 variant='ghost'(含 badgeVariants 的 ghost 變體)— deprecated since N/A(pre-release,未經 deprecation 期),removed in 19.0.0-alpha.121

    • Replacement: variant="soft" 搭配 color="default",以中性淡底呈現
    • Migration: 框架 Badge 的 variant="ghost" → variant="soft";badgeVariants({ variant: 'ghost' }) → badgeVariants({ variant: 'soft' })。其他元件的 ghost 與直接使用 DaisyUI badge-ghost class 不屬此 API 移除。
  • Chip.density / ChipDensity — deprecated since N/A(pre-release,未經 deprecation 期),removed in 19.0.0-alpha.121

    • Replacement: Chip.size / ChipSize
    • Migration: density="compact" → size="sm"、density="comfortable" → size="md"、 density="inherit" → size="inherit"。未指定 size 時為 md,字級為 15px;inherit 改按 15px 基準縮放。 如需保持舊字級,可用 className="text-[13px]";欄位內 chip 不需遷移。
  • Tabs.size / TabsSize('xs' | 'sm' | 'md' | 'lg')— deprecated since N/A(pre-release,未經 deprecation 期),removed in 19.0.0-alpha.114

    • Replacement: Tabs.density / TabsDensity('tight' | 'compact' | 'comfortable')
    • Migration: size="sm" → density="tight"、size="md" → density="compact"、size="lg" → density="comfortable"; size="xs"(24px)無對應 density,改用 tight。字級不再隨尺寸變化,一律 16px
  • MediaInput.thumbnailLayout — deprecated since N/A(pre-release,未經 deprecation 期), removed in 19.0.0-alpha.99

    • Replacement: thumbnailContainerHeight(alpha.99 的暫時替代,只能表達固定高度,不等價於 intrinsic)
    • Migration: alpha.99 期間若需要自然高度只能暫留舊版或自行避開固定高度;此 API 已於 19.0.0-alpha.100 以 thumbnailLayout="intrinsic" 原語意恢復,升級至該版後移除應用端的私有 DOM/CSS workaround
  • Modal / ModalProps / ModalSize(@appfuse/appfuse-web/components)— deprecated since N/A(pre-release,未經 deprecation 期),removed in 19.0.0-alpha.87

    • Replacement: Dialog / DialogProps / DialogSize
    • Migration: 自 @appfuse/appfuse-web/components 匯入的 Modal → Dialog; <Modal … > → <Dialog … >;型別同名替換
    • Note: 同檔若另有 import { Dialog } from '@headlessui/react'(手刻外殼的遺留)會撞名—— 該類檔案本就應改用框架元件,或將 Headless UI 的匯入別名為 HeadlessDialog。 命名沿革見 ADR-010
  • Dialog / DialogProps / DialogVariant(舊語意,prompt 佇列的呈現面)— deprecated since N/A(pre-release,未經 deprecation 期),removed in 19.0.0-alpha.87

    • Replacement: PromptDialog / PromptDialogProps / PromptDialogVariant
    • Migration: <Dialog open title content severity actions … > → <PromptDialog … >(props 不變)
    • Note: Dialog 這個名字未消失,但語意已換——現在指向原本的 Modal(通用可組合外殼)。 兩者 props 幾乎不重疊(舊者必要 content / severity,新者必要 children), 故未遷移的呼叫端會編譯失敗而非靜默渲染成別的元件。應用面通常不直接使用本元件 (全域訊息走 prompt.*() + MessageDialog),fleet 盤點顯示零直接匯入
  • manualPagination(DataTableProps)— deprecated since N/A(pre-release,未經 deprecation 期), removed in 19.0.0-alpha.83

    • Replacement: 無。DataTable 明確只支援後端分頁,元件內部固定 manualPagination: true
    • Migration: 傳 manualPagination={true}(或未傳,即預設)→ 直接刪掉該 prop,行為不變; 傳 manualPagination={false} → 該值原本就無作用(元件未註冊 getPaginationRowModel), 刪除後行為同樣不變。大量資料請由後端分頁後只傳當前頁
    • Note: 新增的 getRowId fallback 是「頁面偏移序號」,前端分頁下 data 為全量、索引已是全域 序號,再加一次 offset 會讓 row id 錯亂——此 prop 與新的 row id 邏輯無法並存
  • striped(DataTableProps / VirtualTableProps)— deprecated since N/A(pre-release,未經 deprecation 期),removed in 19.0.0-alpha.77

    • Replacement: rowSeparator(見 Added)
    • Migration: striped(或不給,預設 true)→ rowSeparator="zebra"(亦為新預設,可直接刪掉該 prop); striped={false} → rowSeparator="none"
    • Note: striped={false} 對到 'none' 而非 'divider'——原本 striped={false} 下留著的是 DaisyUI base-content/5 的原生淡線,肉眼近乎無感,語意上更接近「無列區分」
  • InstantPicker(components/form)— deprecated since N/A(pre-release,未經 deprecation 期), removed in 19.0.0-alpha.73

    • Replacement: DateTimePicker type="instant";只有日期的 Instant 改用 DatePicker type="instant"
    • Migration: 將 InstantPicker 改為 DateTimePicker type="instant",其 Date 值與時區 props 可直接沿用; 原 picker 內的時區換算工具改由 @appfuse/appfuse-web/utils 匯入。
  • AuthConfig.shouldAttach — deprecated since N/A(pre-release,未經 deprecation 期), removed in 19.0.0-alpha.66

    • Replacement: request-level HttpRequestConfig.authMode
    • Migration: 移除 client-level URL matcher,改在 login、refresh、Email OTP challenge/resend/verify 等 公開認證呼叫傳入 { authMode: 'none' };其餘請求沿用預設的 session。
  • MswHealthGuard.enabled / probe / selfHeal — deprecated since N/A(pre-release,未經 deprecation 期), removed in 19.0.0-alpha.64

    • Replacement: 必填 health: MswHealthCoordinator
    • Migration: 以 createMswHealthCoordinator({ enabled }) 建立模組層單例,同時傳給 createHttpClient({ mswHealth: health }) 與 <MswHealthGuard health={health} />。
    • Impact: 使用舊 props 的應用會出現 TypeScript 編譯錯誤;這是刻意的 pre-release 契約收斂。
  • VirtualTable / DataTable 的 per-action 檢視控制開關 — 由 showTableActions 統一取代

    • 涵蓋:showColumnVisibilityMenu、showDensityToggle、showResetLayout、showFullscreen、onDensityChange、onResetLayout(密度切換與重置版面動作一併移除)
    • deprecated since N/A(pre-release,未經 deprecation 期),removed in 19.0.0-alpha.45
    • Replacement: showTableActions(預設 true,收合成單一齒輪;元件會自動抑制沒意義的動作——如無可隱藏欄位時不顯示欄位動作)
    • Migration: 原 showColumnVisibilityMenu={true} 直接刪除即等價(新預設已涵蓋);需整組關閉檢視控制時改傳 showTableActions={false}。showDensityToggle / showResetLayout / onDensityChange / onResetLayout 對應的密度切換與重置版面動作已移除,無替代
  • CollapsibleCard — 由 Card 取代(collapsible 涵蓋其全部能力,且 Card 另補三段式 divided 標頭與 surface 表面對比)

    • deprecated since N/A(pre-release,未經 deprecation 期),removed in 19.0.0-alpha.40
    • Replacement: Card(@appfuse/appfuse-web/components)
    • Migration: <CollapsibleCard …> → <Card collapsible …>(title / summary / defaultExpanded 同名透傳);import CollapsibleCard → Card。不需收合的靜態卡可省略 collapsible;divided 預設 true(如需舊的無分隔外觀傳 divided={false})
  • RichTextEditor 移除 Slate-only 工具函式

    • 影響範圍:直接引用 lib/components/data-input/rich-text-editor/rich-text-editor.utils.ts 的 parse / stringify / clear / reset 的下游程式碼
    • deprecated since N/A, removed in 19.0.0-alpha.18
    • Replacement: 不再需要 — value 已是 HTML 字串,無需轉換
    • Migration: 移除對應 import;reset/clear 改為 editorRef.current?.getEditor()?.commands.setContent('<p></p>')
  • Badge 的 variant='dash'

    • 影響範圍:傳 variant="dash" 給 <Badge> 的下游程式碼
    • 移除理由:與 Toast 的 dash 一併清除,使 daisyUI 的 dash 修飾子不再出現在框架的 任何 variant 詞彙中。注意此處理由與 Toast 不同——Badge 非訊息面,「虛線=佔位/未完成」 對狀態標籤未必衝突(草稿標籤即為合理用途);移除的實際依據是參考實作零使用、 且統一 variant 詞彙。若日後確有「暫定狀態」的視覺需求,應以語意化的方式重新引入, 而非復原 dash
    • deprecated since N/A(pre-release,未經 deprecation 期),removed in 19.0.0-alpha.52
    • Replacement: outline(同為框線外觀,實線)
    • Migration: variant="dash" → variant="outline"
  • ToastVariant 的 'dash'(Toast / MessageToast 的 variant='dash')

    • 影響範圍:傳 variant="dash" 給 <Toast> / <MessageToast> 的下游程式碼
    • 移除理由:虛線框語意偏「佔位 / 拖放區 / 尚未完成」,與訊息面「這是一則真實訊息」相衝。 此推理框架自身早已寫下——Dialog 當初新增 variant 時即明文排除 dash——只是未一併 套用到 Toast。現與 AlertVariant 統一為 'solid' | 'soft' | 'outline' 三值
    • deprecated since N/A(pre-release,未經 deprecation 期),removed in 19.0.0-alpha.52
    • Replacement: outline(同為框線外觀,實線)
    • Migration: variant="dash" → variant="outline";型別 ToastVariant 不再接受 'dash'
  • DialogVariant 的 'outline'(Dialog / MessageDialog 的 variant='outline')

    • 影響範圍:傳 variant="outline" 給 <Dialog> / <MessageDialog> 的下游程式碼
    • 移除理由:原 variant 軸源自與 Toast 的 API 對稱、非 Dialog 自身需求,三值中 soft 與 outline 皆零消費且兩者視覺幾乎難分。soft 後因「扁平動作鍵過於強烈」這個具體需求 重新引入(見 Added),outline 則無對應需求,不再提供
    • deprecated since N/A(pre-release,未經 deprecation 期),removed in 19.0.0-alpha.52
    • Replacement: soft(柔色標題列)或 solid(預設)
    • Migration: variant="outline" → variant="soft"(最接近的柔和外觀)或刪除該 prop 回到 solid。 DialogVariant 型別仍存在,僅不再接受 'outline'

Fixed​

  • DatePicker 與 DateTimePicker 在格式化前驗證 value/defaultValue,不完整字串、不存在的日期與 Invalid Date 改為欄位錯誤,避免渲染拋出 Invalid time value 而進入整頁錯誤畫面。 無效預設值可編輯或清除,合法回填會解除欄位錯誤;Local 值不再截斷或把無效時間補成午夜。 修正 0001–0099 年驗證及 Instant 年份補零,保留合法低年份日期;Form 包裝元件一併受益。

  • 修正 Select 在背景重新渲染與等價選項陣列更新時重設高亮並拉回清單頂端;資料更新依值保留仍可用的高亮, 滑鼠高亮不再觸發自動捲動,鍵盤導航僅捲動清單並避開固定搜尋列與底部動作。 PrototypeToolsPage 穩定情境與故障選項,避免每三秒的模擬信箱更新重建未變更的選項資料。

  • DataTable 與 VirtualTable 的 enableRowClick 自動略過按鈕、連結、輸入欄位、可編輯/可聚焦元素、ARIA 互動角色、選單觸發器及其內部圖示;列外 Portal 事件亦不觸發選取,避免選單剛展開就因 選取更新而關閉。由表格略過選取並保留事件傳遞;內建選取 checkbox 不再攔截 click 冒泡。

  • VirtualList 與表格共用互動控制辨識,補齊 switch/radio/combobox 等 ARIA 角色; 項目外 Portal 的 click、Enter/Space 不再觸發項目選取或阻止命令執行。 一般項目的點擊與鍵盤選取維持原行為,事件照常傳遞。

  • Dialog.draggable 不再讓自訂標題內的 Portal pointerdown 誤啟動標題拖曳或取消預設行為; 標題文字與把手仍可拖曳,標題內的控制項操作保持正常。

  • ReportViewer 與 PdfViewer 在 Firefox 內建 PDF 檢視器中列印時, 不再因註冊 afterprint 事件被拒絕而中斷;仍呼叫一次 print(),並以既有 60 秒逾時回收 iframe 與 blob URL。

  • MediaInput 浮動標籤補上 text-base-content/60,修正未聚焦時繼承完整文字色而比其他 data-input 欄位更深的問題;聚焦時仍依 color(有錯誤時使用 error)顯示語意色。

  • PdfViewer 的列印按鈕先前以 window.open(url, '_blank', 'noopener') 開窗,該呼叫回傳 null,print() 從未執行;改為隱藏 iframe 列印(見 Changed)。

  • PdfViewer 卸載或換來源時會銷毀尚未完成的 PDF.js loading task,不再留下背景載入與 worker 資源。

  • PdfViewer.src 傳入內容相同的新 FileDescriptor 物件(例如每次 render 重建)不再重新載入文件; 以 URL 判斷來源是否變更。

  • FileInput 的輸入容器不再固定 bg-base-100,改為透明,與 Input、Select、TagInput、Textarea 一致。 先前三種變體(含 underlined 與 ghost)在非 base-100 底色的頁面上都會多出一塊白底。

  • PdfViewer(含 ReportViewer、MediaPreviewDialog 的 PDF 預覽)改用 pdfjs-dist 的 legacy build(主程式與 worker)。standard build 直接呼叫 Map.prototype.getOrInsertComputed、Math.sumPrecise 等新 API,在較舊的 瀏覽器(含 Playwright bundled Chromium 143)會顯示「Unable to render page」;legacy build 附帶 polyfill。 文件載入與頁面 render 失敗時,原始錯誤改以 logger.error 輸出到 console。

  • DatePicker/DateTimePicker 月曆的月份導航與年/月快速選單,在年份小於 1000 時產出未補零的年份 (如 1-01),改為一律輸出四位年 YYYY;年份導航停在與分段遮罩一致的 1~9999 範圍。

  • ButtonLink 的 externalLabel 為空字串、純空白或翻譯後空白時,不產生標記元素、間距或無障礙名稱尾端空白。

  • MediaViewer/MediaInput 的 ghost 縮圖網格移除 p-2,保留 gap-2;bordered 留白不變。 縮圖寬度計算保留實際零內距,切換 variant 時同步更新響應式操作排版。

  • MediaViewer 內建縮圖網格改為 gap-2 p-2,與 MediaInput 統一為預設 8px 間距/內距,並同步縮圖尺寸計算的 fallback;bordered 外層容器仍保留 p-4。

  • MediaViewer 內建 thumbnail 操作與全域控制改為依互動優先權顯示:hover 縮圖時隱藏全域控制,移入全域控制區則隱藏縮圖操作;縮圖焦點或操作選單開啟期間鎖定單項操作。離開或移除縮圖後解除抑制,並保留 MediaInput 自訂縮圖透過既有 props 提供的抑制訊號。

  • MediaViewer 的容器 p-4 改為僅套用於 bordered;ghost 不再自帶容器 padding,避免嵌入時與外層留白疊加。需要原有留白可傳入 className="p-4";single/carousel 內容層的 p-2 維持不變。

  • Fieldset 分離左右內縮、標題間距與底部留白:三種 variant 共用 density 對應的上下內距,有 legend 時縮減上方 padding,避免與 legend 留白疊加;無 legend 時恢復完整上方內距,僅 bordered 保留左右內縮。

  • MediaInput 跨來源自家 API 縮圖載入判定漏傳 API base:將 httpClient 的 baseURL 傳入 needsHttpClientFetch,讓完整自家 API URL 經已認證的載入流程,且傳給 get 的 URL 保持原樣;外部 CDN 圖片維持直接載入。此為既有縮圖缺口修正,未改變相對路徑 wire 契約。

  • MediaInput showAddTile 在零媒體時不渲染新增卡片:MediaViewer 原本在 items.length === 0 時無條件短路到空狀態,連帶讓自訂 renderThumbnailContainer 整段不執行——以 displayMode="thumbnail" + showAddTile(含併用 capacitySlots)且 value 為空陣列時, 網格只顯示空狀態,role="option" 為 0,使用者完全沒有上傳入口。現在 thumbnail 模式下只要 呼叫端提供 renderThumbnailContainer,零項目就交由該容器決定空狀態的樣子;未提供自訂容器時 維持既有空狀態。emptyContent 是針對「空」的明確表態,仍優先於容器——兩者並存時渲染 emptyContent。相簿型欄位在「尚無任何媒體」的初始狀態可正常新增第一張。

  • appfuse-ds-snapshot 的層匹配與 schema v10 的 Design Language IA 不相容:CLI 以 story title 的第一段比對層名(Foundations/Vocabulary/Shell/Compositions),但 schema v10 的 canonical Story 掛在 Design Language/{Category}/{Name},首段恆為 Design Language → module 層永不命中, 已完成遷移的模組只會得到「0 module + N framework」的快照,/design-language push 無從產生 visual payload。比對改為先剝除 Design Language 根節點(遷移前的 {Category}/… 舊命名仍相容), 並補上 IA 缺的 Status & Governance、Guidance & Cross-layer、Extensions 三層 (對應 --layers 的 status-governance/guidance-cross-layer/extensions;該層無 story 時照舊略過)。 另兩項行為變更:module 層命中數為 0 時改以非零 exit code 失敗且不寫檔(只含框架層的快照若被 上傳會把四層 active contract 從鏡像整個抹掉),並列出實際掃到的 category 段供比對——確實只要框架段 時用 --layers framework,要沿用舊行為則設 SNAPSHOT_ALLOW_EMPTY_MODULE=1;未被任何層收走的 story 會列出來源與數量,不再靜默丟棄。

  • MediaViewer 沒有媒體時的空狀態不會填滿 viewport:viewport 是 flex 容器,空狀態少了 w-full 會被當成 flex item 縮成文字內容寬度(實測 744px 寬的 viewport 只畫出約 60px)。 bordered 變體的滿版灰底掩蓋了這個問題,ghost 變體(透明背景,如 MediaInput 內嵌) 會直接露出。空狀態改為 w-full h-full;ghost 變體現在會顯示與 viewport 同寬高的空狀態底色。

  • 子路徑部署的 Storybook 無法載入本地 fixture:story 素材 URL 與全域 HttpClient base URL 改為 相對於 iframe 的路徑;本機仍解析為 /fixtures/*,部署在 /storybook/ 時則解析為 /storybook/fixtures/*,修正 MediaInput、MediaViewer、PdfViewer 等 story 的圖片/影片/PDF 404。

  • MediaViewer 控制項原本位於 role="listbox"/role="img" viewport 內:工具列、操作環、 導航及下載控制現在改為 viewport 的兄弟節點,避免互動按鈕被 listbox/img 擁有。這是 DOM 結構 變更:使用 [role="listbox"] > button、[role="listbox"] button 等選取器,或假設 viewport 是控制項 定位父層的自訂 CSS,需改以 [data-media-viewer-frame] 共同定位 wrapper 為準。鍵盤事件同步掛在 該 wrapper,焦點位於控制項時仍保留元件快捷鍵。

  • DataTable/VirtualTable 的 checkbox 選取欄可能被壓窄:選取欄現在保留至少 44px 寬度, 對齊完整觸控命中區;多欄位在窄容器中改由水平捲動承擔空間,不再讓 auto table layout 把 選取欄壓回 checkbox 的視覺寬度。

  • Input/Textarea 的 helper 與 error 未連結至 control:兩個元件現在會由 control ID 產生穩定的 helper/error ID,分別透過 aria-describedby/aria-errormessage 自動建立關聯, 並合併呼叫端既有的 ARIA ID references。error 仍同步設定既有 aria-invalid;ghost variant 另外保留螢幕閱讀器可參照的隱藏訊息,不要求 consumer 建立 wrapper 或自行接線。

  • Button loading 把 DaisyUI spinner mask 套到整顆按鈕:根元素不再輸出 .loading,該 class 只保留於內部 .loading-spinner。loading 時仍使用原生 disabled,並新增 aria-busy="true"; spinner 標為裝飾性內容,文字繼續保留於畫面與 accessible name。compact loading Button 因此維持 40px 高度與正常文字寬度,不再縮成約 34×40px 的 icon-only 外觀。loading 現在也會保留原 variant 與語意色的背景、邊框及文字,不再套用一般 disabled 的灰化視覺;原生 disabled 行為仍維持不變。

  • VirtualList 的 rowPosition 拖曳實際上無法重排:項目把虛擬化位移(translateY(start)) 與 dnd-kit 的拖曳位移放在同一個 transform 上,而 dnd-kit 量測 droppable 走 getTransformAgnosticClientRect——它依 computed style 把節點自己的整份 transform 反推掉, 虛擬化位移一併被消掉,於是每個項目都被量成「容器頂端」那一個矩形。碰撞偵測分不出彼此, over 永遠停在自己身上:項目跟著手指動,放開後卻不重排、onReposition 一次都不會發生 (DataTable / VirtualTable 不受影響——它們的列沒有虛擬化 transform)。啟用拖曳的項目 改以 top 定位(不是 transform,反推不到),並停止套用 useRowPositionRow 為表格列準備的 position: relative(會把絕對定位的項目拉回文件流、瞬移到清單頂端)。未啟用人工位置的 項目維持 transform 定位,合成器效能不變。

  • 拖曳途中把 rowPosition.enabled 翻成 false 會殘留對外的拖曳狀態:DndContext 連同 sensor 一起卸載時 onDragCancel 不會發生,useRowPositionDragState() 因此繼續回報一場 已結束的拖曳,重新啟用時還會短暫復活。現在停用即對外歸零,內部 state 一併清掉。

  • 拖曳把手在觸控上是捲動死區:把手的 touch-none 禁止瀏覽器從把手起始捲動,而長按門檻 又擋下未達 200ms 的拖曳——手指落在把手上快速滑動於是既不拖也不捲,還順帶關掉該元素上的 雙指縮放。改為不設限 touch-action:快速滑動照常捲動,停住 200ms 才進入拖曳(瀏覽器開始 捲動的位移門檻大於長按的 5px 容許值,故長按成立時瀏覽器尚未接手,dnd-kit 的 preventDefault 仍有效)。

  • lift 的插入指示線漏掉所有 sticky 欄:指示線只寫了 [&>td],而 daisyUI table-pin-cols 的設計是 sticky 欄用 <th>、其餘用 <td>——於是把手欄、選取欄、序號欄 與 stickyFirstColumn 全部沒有線,看起來像從第二欄才開始、沒有對齊。td 與 th 現在都畫。

  • 框架注入的三個內部欄都比宣告寬度寬一倍以上:daisyUI 給每個儲存格 padding-inline: 1rem(左右共 32px),把這些窄欄的 min-content 墊高,宣告值從來沒有靠近過 ——把手欄 40px 實測 92px、選取欄 3ch 實測 66px、序號欄 4ch 實測 73px,合計白白擠掉 資料欄逾 100px。三者的內容(把手、checkbox、序號)都置中且不靠內距承擔可讀性,鄰欄的 16px 內距已提供欄間間隔,故表頭與儲存格一律不留左右內距:92→59px / 66→43px / 73→57px。 仍非宣告值:table-layout: auto 下儲存格的 width / max-width 只是提示,剩餘寬度仍會 分配到各欄——fixedWidth 與 STICKY_COLUMN_WIDTHS 是下限指引而非保證。 stickyFirstColumn 不在此列:那是使用者的資料欄,內距在那裡確實承擔可讀性。

  • MediaInput 的縮圖排序在觸控上沒有長按門檻:同時註冊了 PointerSensor(距離 8px)與 TouchSensor(長按 200ms),但 PointerSensor 監聽 pointerdown、觸控也走這條,於是距離門檻 永遠先成立、TouchSensor 形同不存在——註解寫的「觸控長按 200ms」從未生效。改為 MouseSensor

    • TouchSensor,滑鼠門檻不變。
  • Dialog 的訊息本文對輔助技術是「無主文字」:content 先前以裸 <p> 渲染,未掛 aria-describedby——螢幕閱讀器唸完標題後不會接著唸訊息本文,而「訊息本身」正是這支元件 存在的理由。現改以 Headless UI 的 Description 渲染(預設仍為 <p>,樣式與 DOM 結構不變), 由 Headless UI 自動關聯。

  • MessageToast 把譯文而非原始動作字串 resolve 回呼叫端:onAction 直接把畫面上的 譯文交給 prompt.handleAction(),未如 MessageDialog 反查原始鍵。若將 warning / confirmation 導向 Toast,await prompt.warn() 會 resolve 出呼叫端認不得的字串。 兩個容器現共用 resolvePromptAction()。

  • MessageToast 對已翻譯內容二次查詢:content 先前無條件 i18n.t(),後端回傳的 完整句子沒有對應翻譯鍵。現與 MessageDialog 一致,經 translatePromptMessage() 只在 鍵存在時才翻譯。

  • Dialog 與 FileInput 預覽對話框的關閉鈕 aria-label 被翻譯兩次:Button 已擁有 aria-label 的翻譯責任 (見 button.tsx),Dialog 卻再傳 t('Close') 進去,形成 t(t('Close'))。多數語系下 第二次查詢因查無鍵而回傳原字串、僥倖看不出問題,但只要譯文本身撞上另一個翻譯鍵就會譯錯。 兩處現與 Modal 一致,只傳 raw key。

  • Dropdown.Content 的點擊會外洩到 <Dropdown> 的祖先,最明顯的症狀是 FileInput 的 chip 選單:選單雖經 Portal 渲染到 body,但 React 合成事件仍沿 React tree 冒泡——chip 選單位於 上傳容器(role="button" + 開啟選檔對話框的 onClick)之內,於是點選單任一處都會連帶跳出瀏覽器 選檔對話框,連預覽、下載鈕也不例外(動作照常執行,同時多開一個選檔視窗)。Dropdown.Content 現在攔下自身的點擊冒泡(僅 click;mousedown / pointerdown 等仍照常冒泡,由各自情境 處理)。選單項自己的 onClick 先於此執行(bubble 階段)故不受影響;關閉選單的 document 監聽 聽的是 mousedown,與 click 不同型別故不受影響。下游需留意:合成事件的 stopPropagation() 會一併呼叫 nativeEvent.stopPropagation(),而 React 把 portal 的監聽掛在 body,因此 document 層級的原生 click 監聽收不到選單內的點擊(分析埋點、第三方 outside-click 套件可能受影響;框架內部無此類監聽)。同類情境(可點擊的卡片/表格列內放 Dropdown)一併修正。

  • DatePicker / DateTimePicker / TimePicker 的受控值被清成 null 時畫面仍留著舊日期: 分段遮罩引擎(useSegmentedMask)為了保護半途輸入,一律不讓空的外部值覆蓋內部 draft—— 「打了年、月日未填 → 值為 null → 回聲把 draft 洗掉」是必須擋的,但這道保護連外部清空 也一併擋下,於是表單 reset()、連動欄位互清或非同步回填 null 之後,欄位仍顯示先前的日期 (值是 null、畫面卻看似有值);聚焦中被清空時更會永久殘留。修正改以「這個空值是誰造成的」 分辨:外部確有變動且非本次 apply() 的回聲才追平(畫面同步清空,聚焦中亦然),自家 onChange 送出的空值一律忽略。半途輸入的保護不變——鍵入未完成的分段、清除單一分段、以及 blur 後殘留的片段皆不受影響。注意「外部清空」以值確實從有值變成空為準:對本來就是 null 的值再清一次沒有變動可偵測,畫面上的半途輸入會留著(值與畫面皆代表沒有值,不矛盾),要一併 清掉請走清除鈕的 reset()。

  • 表單欄位的 error 未同步 aria-invalid,視覺狀態與可及性狀態不一致:欄位依 error 套用 input-error 視覺樣式並顯示錯誤訊息,卻沒有標記 aria-invalid,螢幕閱讀器讀不到無效 狀態,每個下游應用都得在呼叫端重複補救。現在所有具 error prop 的欄位一致遵循:呼叫端明確 指定 aria-invalid 時以呼叫端為準;未指定時只要 error 存在即標記 aria-invalid="true"; 無錯誤時維持未設定(不輸出 aria-invalid="false")。涵蓋 Input、Textarea、Checkbox、 Radio、Switch、Select、TagInput、FileInput、MediaInput、RichTextEditor,以及 委派 Input 的 DatePicker / DateTimePicker / TimePicker 與 lib/form/ 各表單版元件 (FileInput / MediaInput 的檔案驗證錯誤亦納入判斷)。CheckboxGroup / RadioGroup 的 群組層級 error 會落到每個選項控制項上(群組訊息仍只渲染一則,選項不轉紅)。OtpInput 原已正確,行為不變。

  • Checkbox 的 indeterminate 在點擊後失效、錯誤呈現為未勾選:依 HTML 規範,使用者點擊 checkbox 會把 indeterminate 原生重設為 false;React 會還原受控的 checked,但 indeterminate 不是它管理的屬性。同步 effect 原本只在 prop 值變動時執行([indeterminate]),於是「點擊前後 prop 皆為 true」的情境會卡在原生重設後的 false。修正為每次 render 都同步,並在 onChange 當下 立即還原(外部狀態未因這次點擊改變時不會 re-render,光靠 effect 補不到)。副作用:元件因此恆有 onChange,React 的「傳了 checked 卻沒有 onChange」開發期警告不再出現——本元件是受控包裝, 唯讀請用 readOnly。

  • DataTable / VirtualTable 的序號欄在元件內排序後顯示原始資料位置:序號取自 row.index,那是 core row model 的索引——getSortedRowModel 只把它當穩定排序的 tie-breaker、不重寫。後端排序時 data 進來就是排好的,兩者恰好相等,這個巧合掩蓋了問題; 改為元件內排序後使用者會看到 2、3、1。改取顯示位置(以 row.id 在當前 row model 中查找—— 排序後的 row 是淺拷貝,物件識別比對必然失敗)。

  • onSelectAll / onDeselectAll 依 README 的寫法使用會無窮 re-render(DataTable / VirtualTable):元件每次 effect 都交出新函數,而 README 文件化的 onSelectAll={(fn) => setSelectAll(() => fn)} 是 inline 箭頭、每次 render 都是新識別,兩者形成 「交出新函數 → 呼叫端 setState → 重新 render → effect 依賴變動 → 再交出新函數」的迴圈(實測會 讓測試 worker 卡死)。修正為以 ref 讀取最新 table 實例、useCallback 零依賴,交出的函數識別穩定。

  • 表頭全選鈕的 indeterminate 判斷被殘留 selection key 汙染(DataTable / VirtualTable): TanStack 的 getIsSomeRowsSelected() 以 Object.keys(rowSelection).length 為分子、當前列數為分母, 會把「對應資料已離開 data」的殘留 key 一併計入。篩選後選 5 筆、剩 3 列可見其中 1 列選取時, 5 < 3 為 false 使 indeterminate 關閉、getIsAllRowsSelected() 也為 false,checkbox 於是呈現全空, 儘管畫面上明明有一列打勾。改為只依當前 row model 統計。

  • 表頭全選鈕在封頂 indeterminate 時按不掉(DataTable / VirtualTable): getToggleAllRowsSelectedHandler() 依 e.target.checked 決定方向,而 checked 在多頁/尚有未載入 資料時恆為 false,每次點擊都變成「全選」。改為依「當前列是否已全選」決定方向。

  • Select.options 拒絕具名 interface 選項——公開 Option 的物件分支原為 Record<string, unknown>,要求字串 index signature,導致一般的領域 interface 無法指派並出現 TS2322。改為接受任意 object,元件內部僅在解析動態 accessor 時局部轉為 record;components 與 form 入口同步生效,既有原始值與物件字面值用法不變。

  • Dropdown.Trigger 覆寫 child 的 onClick、onKeyDown 與 ref——改為先執行框架的開關/鍵盤 邏輯,再呼叫 child handler,並同步寫入框架與 child ref。觸發鈕現在可直接以 event.stopPropagation() 避免冒泡到 DataTable row click,不需額外 DOM wrapper;React 19 callback ref 回傳的 cleanup 也會保留。FileInput chip 已改把 stopPropagation 直接放在 trigger,移除示範舊 workaround 的外層 wrapper。

  • DataTable 的分頁器與表格內容之間缺少穩定分隔——PaginationBar 補上 border-t border-base-content/10

    • 原本三種 rowSeparator 都受影響:divider 與 none 完全沒有分隔;zebra 看似有分隔 其實是偶然——最後一列為偶數列才有底色,奇數筆資料就沒有
    • 線加在分頁器而非表格最後一列:它分隔的是「表格區 / 分頁器區」兩個 UI 區塊,屬於 分頁器的上緣。這樣三種 rowSeparator 都受益,且沒有分頁器時表格最後一列維持無 border (下方沒有相鄰區塊要分隔,那會是懸空的收邊);兩者若並存還會出現相距 py-2 的平行線
    • 取 /10:比 DaisyUI 原生列線(base-content/5)明顯以撐起區塊界線,又比 divider 的 列線(/20)輕,避免在 zebra / none 模式下突兀
    • VirtualTable 無內建分頁器,不受影響
  • Button 的 outline 與 soft 在鍵盤焦點時填色比滑鼠 hover 淺——補上與 hover 等價的 focus-visible 變深規則,兩者現在完全一致

    • 根因:DaisyUI 把 .btn 的變深規則只掛在 @media (hover:hover){&:hover{…}}, :focus-visible 沒有對應規則;而 .btn-outline 與 .btn-ghost(soft 的底)都以 :not(…, :focus-visible, …) 排除選擇器實作「常態透明」,focus 時該條停止套用、--btn-bg 回落到 --btn-color 原色而非 hover 的變深色
    • outline:所有 color 皆受影響(實測 primary:focus L=0.5 / hover L=0.465)
    • soft:只有 color="default" 受影響(實測:focus L=0.97 / hover L=0.90)——有色時 softTextColorClasses 自帶的 focus-visible:bg-{color} 直接設 background-color、勝過 --btn-bg 變數,早已把 focus 拉齊 hover,但 default 那格是空字串、無人補救
    • 本次把該式子抽成共用常數,由 solid(原已手寫同式)、outline、soft 共用,避免三者 各自漂移;ghost 刻意無填色互動故不補,link 走 underline
    • 影響:受影響組合的焦點填色略為變深,與各自的 hover 一致;有色的 soft 行為不變(新常數 對它是 no-op)。color="default" 因 --btn-color 未定義而 fallback 到 --color-base-200, 焦點填色雖已對齊 hover 但仍偏淺,該色的焦點可見度另案處理
  • DatePicker 的無效 instantTime 直到點選日期才從事件 handler throw——移除 handler throw;未提供值時採文件化的 start-of-day 預設,但已提供的無效值會在欄位初次 render 顯示 inline error,不會退回預設、後續點選也不送出部分值。字面型別不再使用會接受 1.5/負數/ 科學記號的 `${number}` 片段。

  • DatePicker/DateTimePicker 收到非法 IANA zone 時於 render 期直接 throw——先驗證 zone,錯誤改走 Input inline error;切換到非法 zone 或 DST gap 時保持時區與 Instant 原子性,不做部分更新。

  • DayStatus.date 查找與選取的正規化策略不對稱——現在只接受真實存在且精確符合 YYYY-MM-DD 的值;完整 timestamp 或非法日期顯示欄位合約錯誤,不再靜默查不到或截斷。

  • form DatePicker/DateTimePicker 把型別不符的欄位值靜默吞成 null——name 現依 picker 模式 限制到相符值型別;執行期錯誤型別或 Invalid Date 會在欄位顯示 inline contract error,不會跨型別 轉換、改寫原始表單值或從 render 丟出例外。只有 nullish 代表缺值。

  • Refresh error 判準不再由下游複製 status === 401:framework 的共用 classifier 僅把 stable refresh-session-invalid 視為正常拒絕,並為無 code 的 legacy 401 提供有 warning 的相容出口; proactive refresh 自製的 ErrorResponse 也補齊 URN type 與 errorCode。

  • DatePicker / DateTimePicker / InstantPicker 窄寬度下的格式化值會與清除、日曆按鈕重疊—— picker 的自訂 rightAddon 實際包含兩顆 24px 按鈕與間距,先前共用 Input 只將整個 slot 當成單一 addon,僅預留 40px。現在有值且顯示清除鈕時預留 64px;並依欄位實際可用寬度 量測,完整預設格式放不下時只省略星期,極窄時再以省略號收尾,不再穿入按鈕區。 InstantPicker 透過 DateTimePicker 共用同一修正;自訂 formatValue 不會被擅自拆解。

  • PdfViewer:在搜尋框 / 頁碼框打字會誤觸元件快捷鍵——viewport 的 keydown handler 沒有檢查 e.target,而兩個輸入框都在 viewport 內、事件會冒泡上來:打 r 會同時把頁面轉 90°、按 ←→ 是 翻頁而不是移游標、打 - 會縮小。改為焦點在 input / textarea / contenteditable 時,元件 快捷鍵一律讓位(Ctrl/Cmd+F、F3 等不與打字衝突者除外)。

  • MediaInput:鍵盤 focus 時出現兩個外框,其中一個壓在 floating label 上——內嵌的 MediaViewer viewport 是 tab stop 卻沒有處理瀏覽器預設 focus ring,鍵盤(:focus-visible) 進入時 Chrome 會在欄位內側再畫一圈藍框,位置正好穿過 notch / label(滑鼠點擊不觸發 :focus-visible,所以只有鍵盤看得到)。MediaViewer 改為一律關閉 UA outline、需要時改畫 主題化 inset ring,並新增 focusIndicator(預設 true)讓宿主關閉;MediaInput 傳 false (欄位外框與 label 已表達焦點)。

  • PdfViewer:Tab 進入後要多按一次才到主要顯示區——tab stop 原本是包住「側邊欄 + 頁面 + 工具列」的整層外殼,不是頁面本身。改為頁面區才是 tab stop(外殼降為單純容器,keydown 仍留在 外殼上,故焦點在工具列按鈕時方向鍵等快捷鍵照常作用)。側邊欄開啟時 tab 序為「側邊欄(左)→ 頁面區(右)」,與視覺一致;側邊欄關閉時第一站直接就是頁面區。頁面區同步改用主題化 inset ring 取代 UA outline。

  • MediaViewer 控制項的 tab 序與視覺位置不一致——控制項皆為絕對定位,tab 序由 DOM 序決定, 但 DOM 序是「導航(下)→ 操作環(中右)→ 工具列(上)→ 下載(右下)」,Tab 會由下往上跳。 改為依視覺位置由上而下、由左而右排列 DOM;操作環內部同理(扇形是由下往上排的,改為依實際 offset 排序),展開鈕則排在被它控制的操作之前(disclosure 慣例)。版面與動畫不受影響 (幾何與延遲仍依原 index 計算),工具列補 z-10 維持原有疊放層次。

  • PdfViewer / MediaViewer:Tab 會把焦點送到看不見的工具列按鈕上——工具列 hover 才顯示, 但按鈕是 tab stop,鍵盤使用者的焦點會落在不可見處(WCAG 2.4.7)。改為焦點進入元件內即顯示 控制項(focus-within),焦點離開才收回。

  • MediaInput / MediaViewer:鍵盤模型對齊 PdfViewer(方向鍵選項目、Tab 停留在操作按鈕)

    • MediaInput 工具列的「刪除」「新增檔案」先前是 tabIndex={-1}(鍵盤到不了,只能靠 Delete / U 快捷鍵),改為正常 tab stop。
    • MediaViewer 檔案導航的圓點(Go to item N)改為 tabIndex={-1}——它是「選第 N 個項目」、 屬方向鍵領域,且數量隨項目成長(10 張圖 = 10 個 tab stop)。固定數量的 ‹ › 兩顆維持 tab stop。
    • MediaViewer 操作環(縮放 / 旋轉 / 重置)收合時是 scale-0 但仍在 tab 序,焦點會落在看不見的 按鈕上;改為焦點進入即展開。
    • 結果:元件內的 tab stop 數量不再隨項目數成長(5 張圖的 thumbnail 模式為 6 個、single 模式 14 個,皆為固定操作按鈕)。
  • PdfViewer:縮圖側邊欄每頁各佔一個 tab stop——300 頁的 PDF 就是 300 個 tab stop。改為側邊欄 本身是單一 tab stop(role="listbox" + aria-activedescendant),進入後 ↑↓ 選頁、Home/End 跳 首末頁,縮圖為 role="option"、tabIndex={-1};←→ 仍冒泡給主區的翻頁 handler。側邊欄與主區 驅動同一個 currentPage,行為不分岔。

  • PdfViewer:放大超過容器寬度時,頁面左半邊捲不回去——捲動容器用 justify-content: center 置中頁面,flex 的 center 對齊會把溢出內容推到 scroll origin 之外,該區域永遠無法捲到。改用 safe center(溢出時退回 start 對齊),兩端皆可捲到。

  • PdfViewer:使用者傳入 ref 時,內部量測失效——ref={ref ?? containerRef} 使外部 ref 取代內部 ref,導致響應式側邊欄隱藏(容器 < 600px)與全螢幕狀態偵測失去參照。改為合併 ref, 兩者並存。

  • MediaInput:每顆縮圖各佔一個「按了沒反應」的 tab stop——縮圖展開了 dnd-kit useSortable 的 attributes,帶進 role="button" / tabIndex={0} / aria-roledescription="sortable";但元件 只註冊 Pointer / Touch sensor(無 KeyboardSensor),那些 tab stop 按鍵盤什麼也做不了,還對螢幕 閱讀器謊報可鍵盤排序。改為不展開 attributes,鍵盤操作一律走 MediaViewer 的 roving selection。 滑鼠 / 觸控拖曳排序不受影響(行為由 listeners 提供,非 attributes)。

    • 連帶修正焦點與作用對象不一致:先前焦點在第 3 顆縮圖按 Delete,刪掉的是選取中的第 1 顆; 現在 Delete 一律作用於 aria-activedescendant 指向的項目。
    • 連帶修正刪除後焦點掉回 <body>(被刪的節點持有焦點):焦點恆在 viewport,刪除後仍在原位。
  • MediaInput:觸控裝置無法刪除已上傳的媒體——縮圖的刪除鈕只以 group-hover:opacity-100 顯示,觸控裝置沒有 hover 事件、該鈕永不出現;它又是 tabIndex={-1},鍵盤也到不了,等於功能 不可達。改為 (hover: none) 時恆顯示(與其他元件的 (hover: hover) 判準互補)。

  • 浮動控制項隱藏時仍會吃掉點擊——PdfViewer(工具列 / 頁碼列)、MediaViewer(工具列 / 檔案導航 / 操作環 / 下載鈕)、MediaInput(縮圖刪除鈕)、DataTable / VirtualTable 的 TableActionSpeedDial、VirtualTable / VirtualList 的回到頂部鈕,先前皆只以 opacity-0 隱藏,看不見的按鈕仍攔截其下方內容的點擊。隱藏時一併關閉 pointer-events(鍵盤 Tab 不受影響, focus 進入仍會使控制項顯示)。

  • Server availability 錯誤分類與恢復流程:取消中的 Axios request 不再被誤判為網路中斷;收到 明確 5xx 回應的 mutation 不再標成「結果未知」,只有未收到任何 response 的傳輸失敗才需對帳。 恢復確認若重取資料失敗會保留遮罩、顯示錯誤並允許重試;recovered 期間再發生連線異常也會重新 探測並回到 unavailable,健康複探不會重複發布 server/recovered。結果未知清單可獨立清除,不再 依賴完整的中斷恢復狀態流程;若 Server 全程健康而只有單筆傳輸結果不明,使用獨立的對帳文案, 不再誤稱「連線已恢復」。System-info probe 只要求具鑑別力的 version,不耦合 optional metadata。

  • MswHealthGuard:對 MSW 最常見的失效模式完全漏報:原判準 navigator.serviceWorker.controller != null 抓不到「Service Worker 被閒置終止後重啟、 activeClientIds 落空」這個情況——分頁進背景後瀏覽器節流頁面計時器,MSW 每 5 秒的 keepalive 被拉長到超過瀏覽器對閒置 worker 的終止門檻 → worker 被終止;下次請求雖會喚醒它,但重啟後 activeClientIds 是空集合,mockServiceWorker.js 遂對所有請求 passthrough。此期間 controller 恆為非 null(它反映的是「哪個 registration 控制本頁」,不是 worker 是否仍在攔截), 故守衛恆判為健康、從不亮起。改以 probe 實際探測後可正確偵測;未傳 probe 時維持原判準, 行為完全向後相容。

    實測補充:穿透後拿到什麼取決於 dev server 設定——無 /api proxy 時為 SPA fallback index.html (200 + text/html)、有 proxy 但後端未啟動時為 500、有 proxy 且後端已啟動時為 200 + application/json + 真實後端資料。最後一種只有 mock-only marker 分辨得出來,且因請求「成功」, 重試機制連觸發的機會都沒有——這也是「靠 React Query retry 救回攔截失效」這個說法不成立的原因 (重試會喚醒 worker,但喚醒 ≠ 重新註冊 client)。

  • RichTextEditor:整頁載入(deep link / 重新整理)時整棵子樹被 error boundary 接走: 受控同步 effect 以 if (!editor) return 當守衛,但 useEditor 在 StrictMode 重掛載 (及卸載競態)期間會先回傳已 destroy 的舊實例——該實例非 null,其 view / schema 卻已 釋放,editor.getHTML() 於是在 ProseMirror 的 DOMSerializer.fromSchema(null) 拋 TypeError: Cannot read properties of null (reading 'cached')。症狀是「從列表點進編輯頁正常、 直接輸入網址或按 F5 就整頁壞掉」,使用者編輯中重新整理會看到錯誤頁。修正:受控同步、 setEditable、imageUpload storage 三個 effect 一律改用 if (!editor || editor.isDestroyed) return 守衛(TipTap 對 editor 副作用的建議寫法)。不影響既有行為——受控 value 仍照常灌入編輯器。

  • Select / TagInput:中文/日文/韓文輸入法組字時,按鍵被元件搶走:兩者的 onKeyDown 未辨識 IME 組字狀態——Select 搜尋框組字期間 ↑↓ 會移動選項 highlight(候選字選不動)、Enter 會直接選取 highlighted 選項並收起下拉(候選字還沒確認,搜尋字也被清掉),中文搜尋(如 Select/AsyncCustomerSearch)幾乎無法用鍵盤完成;TagInput 則會把尚未確認的組字中間態 (注音符號等)當成 tag 加進去,Backspace 亦會誤刪最後一個 tag。修正:組字期間一律不攔截按鍵, 全數留給輸入法。判定同時採 nativeEvent.isComposing(Chrome / Firefox)、keyCode === 229 (舊版瀏覽器)與自記的 composition 旗標——後者為 Safari 而設:其 compositionend 早於 「確認候選字」的 keydown,該刻 isComposing 已為 false,只有把旗標延後一個 macrotask 才放下 才擋得住。組字結束後另按的按鍵行為完全不變。

    • 共用 IME 引擎:組字旗標、三重按鍵判定與 composition 處理器抽為 data-input/ime-composition 內部共用 hook(比照 chip-color / file-download),由 Select 與 TagInput 共用,不重複實作
  • Select(async):IME 組字中間態會打出無意義的搜尋查詢:搜尋框的 onChange 對每次輸入 都送 onInputChange,用注音/拼音打字時每個中間態字元(ㄏ、ㄏㄨㄚ…)各觸發一次後端查詢。 修正:組字期間不送查詢,落定後以最終文字送出一次;顯示文字仍即時同步(受控 input 於組字期間 被回退會打斷 IME)。非 IME 輸入(英數)維持即時送出。查詢時機的判定點在 compositionend 之後 一個 macrotask,確保讀到的是瀏覽器補送 input 事件後的確定文字。

  • Select(單選):再點已選中的選項會把它取消掉:選取邏輯對單/多選共用同一條 toggle (selectedValues.includes(value) ? [] : [value]),使單選也具備「再點一次取消」語意——但那是 多選才友善的操作;單選使用者再點同一項的預期是「確認這個選項」(對齊原生 <select>), 卻換來欄位被清空。修正:單選一律 newValues = [optionValue],不再取消選取;多選的 toggle 行為不變。清除入口不受影響、仍可清空(非 ghost 的 X 鈕、ghost 的下拉「—」選項、下拉收起時的 Delete / Backspace)。

  • DatePicker / DateTimePicker / TimePicker / InstantPicker:從月曆/選單選取、或按清除鈕後,輸入框不更新: 選取/清除後以 setInternalValue 更新值(非同步),但收合/清除時於同一批次同步 focus() 回輸入框 → onFocus 讀到尚未更新的舊 values 並 setDraft(舊值),把分段 draft 停在舊值(清除時更會洗掉 reset() 剛清空的 draft),編輯態顯示走 draft → 輸入框停在舊值(uncontrolled 尤其明顯,如 Storybook DatePicker/Default)。修正兩處:① useSegmentedMask 於編輯期間、外部值確有值且與 draft 內容不同時把 draft 追平到外部值(空值不覆蓋半途輸入、相同值不觸發迴圈);② 移除 onFocus 內讀舊 values 覆蓋 draft 的 邏輯(同步統一由前者處理),讓清除後的空 draft 得以保留。四個 picker 共用此遮罩,一併修復。

  • Select / TagInput 停用(disabled)降淡樣式從未生效,外觀與 Input 不一致:兩者的容器是 <div>(非表單元素),Tailwind disabled: 變體所需的 :disabled 偽類永不匹配,整組停用樣式為 死碼——停用的 Select 文字全彩、TagInput chips 全彩,與唯讀難以區分(Input 為 /70 半透明)。 修正:改用 aria-disabled: 屬性變體(TagInput 容器連帶補上 aria-disabled 屬性),文字濃度由 /50 對齊 Input 的 /70,分支結構鏡像 Input(notch 模式容器邊框維持透明、由 fieldset 提供邊框; ghost 維持透明;其餘 border-base-content/20);multiple chips 以 opacity-70 等效降淡(chip 自帶色彩、不吃容器文字色繼承)。連帶修正 Select 停用/唯讀游標:無條件 cursor-pointer 與 條件式 cursor-not-allowed / cursor-default 為同屬性疊加、勝負取決於 CSS 產出順序(實測 pointer 恆勝),改為三態互斥條件式,停用現正確顯示 not-allowed

  • DataTable / VirtualTable 使用者調整的欄序在 re-render 時被打回原形:欄位定義變動的偵測以 naturalColumnOrder 的參照為準(useEffect 的相依),而該值 useMemo 自 columns prop—— 呼叫端若把 columns 定義在 render 內而未 useMemo(常見寫法,且元件從未要求要 memo),每次 render 都是新參照,於是任何一次 re-render 都會把欄序重設回自然順序。使用者拖好欄位,下一次 狀態更新就白拖。修正:改以欄位 id 的內容比對,且欄位真的變動時採調和而非整份丟棄—— 仍存在的欄保留使用者順序、新欄插回自然位置、已刪除的欄移除(可見性同步調和,丟棄已不存在的欄)。 連帶把 reconcile 由 useEffect 改到 render 期間進行,少一輪串聯重繪。 兩個表格的版面狀態邏輯一併收斂到共用的 useColumnLayout

  • 表單版 <FileInput>(lib/form/)未提供 onDownloadAll 時,「下載全部」按鈕完全無作用: useFileInput 無條件把 onDownloadAll 包成一個恆為 truthy 的 wrapper 函式傳給 BaseFileInput, 而 BaseFileInput 以 if (onDownloadAll) 判斷要走「使用者自訂下載」還是「預設 JSZip 打包」。 wrapper 恆存在 → gate 永遠命中自訂分支 → 呼叫 undefined?.() = no-op → 預設 JSZip 打包路徑 永遠到不了;按鈕按下去不打包、不發請求、不報錯。修正:useFileInput 直接透傳原始 onDownloadAll(未提供時為 undefined),讓 base 的 gate 正確分流。純 UI 版 (lib/components/)不受影響(本就直接收 prop)

  • MediaViewer 自訂縮圖 renderer 收到未解析的 src → 帶認證的伺服器媒體在 gallery(縮圖)檢視永遠轉圈圈: 內建縮圖路徑以 getDisplaySrc 解析出 blob url 再顯示,但走自訂 renderThumbnail 時(如 MediaInput 的 gallery)卻傳原始 item(src 為相對 / API url),自訂 renderer 判定 needsHttpClientFetch=true 顯示 spinner、又無從取得 blob url → 卡死;單張 / 輪播檢視正常(走 getDisplaySrc)。修正:傳給自訂 renderer 的 item 也置換為已解析的 getDisplaySrc(item)(保留其餘欄位)。連帶讓 MediaInput 的 gallery 正確顯示伺服器媒體縮圖。

  • createHttpClient 攔截器對 responseType: 'blob' 請求的 HTTP 錯誤,遺失 RFC 7807 契約訊息: blob 請求失敗時 error.response.data 也是 Blob,而攔截器的 isRecord() 明確排除 Blob → ErrorResponse 被建成空殼(只有 status,detail / violations 全丟)→ getApiErrorMessage 顯示不出後端具體訊息。修正:攔截器在建 ErrorResponse 前,若 data instanceof Blob 先把 JSON 錯誤 body 從 Blob 讀回物件(非 JSON 則退回空物件,與現狀同)。此為通用修正,惠及所有 responseType: 'blob' 的下載請求,非僅 FileInput。401 auto-refresh 不受影響(於 blob 轉換前處理)

  • VirtualTable height 傳百分比時,在非 flex 父容器(如 grid cell)內塌成 0 高:百分比會切到 flex/fill 佈局,但根容器只取 flex-1 min-h-0——flex-1 僅在 flex 容器內有效,放進 grid cell 或一般 block 時是 no-op,捲動層的 absolute inset-0 於是撐在一個 0 高的容器上,整個表格不可見。三欄式 Finder 這類 grid 版面首當其衝。修正:根容器改取 flex-1 h-full min-h-0——在 flex column 內 flex-1 的 flex-basis: 0% 仍主導主軸尺寸(h-full 無害、行為不變),在 grid cell / block 內則由 h-full 生效。實測:grid cell 內 0 → 400px,flex column 內維持既有的「填滿剩餘空間」語意。同時把此佈局約束補進 height 的 JSDoc(原本完全沒有文件)

  • VirtualTable showScrollToTop / onScrollChange 在未提供 onFetchNextPage 時完全失效:捲動監聽器以 if (!scrollElement || !onFetchNextPage) return 開場,使整個 scroll listener 在沒有無限捲動來源時根本不掛上——回到頂部按鈕永遠不出現、onScrollChange 永遠不觸發,兩者於是隱性依賴「必須同時傳 onFetchNextPage」。但這兩項與分頁無關:純虛擬化的長清單(資料一次載完、無分頁來源)同樣需要它們。修正:onFetchNextPage 的守衛下移到只守「載入更多」那一段,位置回報與回到頂部按鈕改為無條件生效。既有測試只驗過「初始不顯示」、從未驗過「捲動後會顯示」,故長期未被發現;已補上兩則捲動後的斷言

  • DataTable 欄位可見性(齒輪的「欄位可見性」動作)在未提供 columnVisibility / onColumnVisibilityChange 時勾選無效:先前 state.columnVisibility 無條件帶入(預設 {})使其恆為 controlled,未提供對應回調時遭凍結——齒輪下拉可勾選卻不會隱藏欄位(序號欄、欄序拖曳皆有未控 fallback、正常運作,唯獨欄位可見性沒有)。修正:比照 columnOrder 補上未控 fallback(columnVisibility 未提供 → 內部狀態管理、零 props 即可運作;提供則為 controlled,經 onColumnVisibilityChange 通知)。columnVisibility 預設由 {} 改為 undefined 以區分受控/未控:ISO8601_REGEXP 的時間段原為可選((T…)?),使 "2025-12-21"(LocalDate)與 "2025-12-21T14:30:00"(無 offset 的 LocalDateTime)被判為 ISO 日期 → response decode() 將其 parseISO 成 Date(本地午夜的時間戳);一旦以 UTC 取日期(toISOString().slice(0,10))或跨時區使用即偏移一天,且守衛測試 should reject incomplete ISO 8601 實為紅燈。修正:regex 要求帶 offset/Z——唯有時間軸上的 instant 才轉 Date,LocalDate / LocalDateTime 一律留字串。連帶 form Input(type=date / datetime-local)與 form DatePicker 不再把輸入轉 Date(見上方 Added):表單值即後端 LocalDate / LocalDateTime 的 JSON 字串形狀,寫出免轉換、免時區陷阱;讀回(decode 保留字串)與寫出(encode 原樣送字串)對稱

  • RichTextEditor autoResize(預設開啟)時編輯區塌成只剩一行高:autoResize 內容區同時掛了 inline min-height: <minRows×1.5>em 與 class !min-h-0(min-height: 0 !important),而 stylesheet 的 !important 會蓋掉非 important 的 inline style,使 min-height 解析為 0、編輯區塌到內容自然高度(約一行)。改為比照 container 既有作法(!autoResize && densityStyles.container):autoResize 時不套 density 預設 min-h class、純由 inline minHeight 控制,移除 !important 衝突。density 的 min-h 從 densityConfig.content 拆到新欄位 contentMinHeight(僅非 autoResize 時套用)。預設用法 <RichTextEditor … />(autoResize、minRows=3)現正確呈現 ~3 行(4.5em)下限

  • FileInput ghost variant 在多 chip 排滿時,最後一個 chip 會與「下載全部」鈕重疊(chip 容器未為絕對定位的右側按鈕群組預留空間);改以動態 pr 依右側鈕數預留空間修正

  • RadioGroup 群組 label 現在會自動透過 t() 翻譯,與其他表單組件(Input、Select 等)的 i18n 自動翻譯規範一致。先前必須在呼叫端手動 t()

  • RadioGroup.required 不再連帶在個別 Radio label 顯示 *,僅在群組 label 顯示一次(對齊 CheckboxGroup 行為與 MUI FormControl required 設計)。先前每個選項 label 後都會被加上 *

  • Radio 未選取邊框:覆蓋 daisyUI 預設 /20 透明度為 text-base-content(100%),把 radio 視為「欄位內容」一員,與 Input 文字內容色一致

  • Checkbox / Radio / Switch hideLabel 且未提供 icon 時,label 容器使用 sr-only(避免空 label 仍佔 flex item 並 trigger 外層 gap-1.5,在 flex justify-center 的表格 cell 內造成 3px 居中偏移)。有 icon 時仍保留 visible flex container 以實現 icon-only 視覺

  • Checkbox / Radio / Switch inline 模式下,外層 wrapper 為 <span>(避免嵌入 <p> prose 段落時觸發 HTML 違規 <div> cannot be a descendant of <p> 與 React hydration warning),內層 inline-flex items-center,視覺上 input ↔ label 排列方式不變

    • 行為變更:inline 模式下不再渲染 error 與 helperText(這兩個訊息在 prose 文字流中顯示本來就會破壞段落結構,應由外圍表單統一呈現)
    • Ref 變更:inline 模式下 ref 指向 HTMLSpanElement;非 inline 模式仍為 HTMLDivElement。下游若有透過 ref 操作 inline Switch/Radio/Checkbox 的需求,需自行用 Ref<HTMLElement> 或 union 型別接收
  • MediaInput / MediaViewer toolbar 與縮圖刪除鈕統一 hover-to-show 行為:

    • 先前 MediaViewer 內部 renderNavigationControls / renderImageControls(含 Settings2 expand 與展開後的 Zoom/Rotate/Reset fan)已採「hover 才顯示」模式(透過 hovering React 狀態),但 renderToolbar(Delete / Upload / Toggle Grid / Fullscreen)與 SortableThumbnail 刪除鈕仍永遠顯示,造成不一致
    • 統一修正:renderToolbar 改為 hovering ? 'opacity-100' : 'opacity-0' 與其他控制群一致;SortableThumbnail 刪除鈕 hover:opacity-100 → group-hover:opacity-100,並在 MediaInput 的內層 positioning wrapper 加 group class 提供 CSS hover 上下文
    • 結果:hover MediaInput 任意處 → toolbar 與縮圖刪除鈕全部淡入;mouse leave → 全部淡出。先前 SortableThumbnail 用 hover: 需要 hover 到(透明的)按鈕本身才出現,使用者幾乎無法觸發
  • MediaInput / MediaViewer 控制鈕尺寸統一為 24px:

    • MediaViewer 與 MediaInput 的平移功能 prop 由 scrollable 直接更名為 pannable(預設仍為 true),精確反映其控制的是媒體拖曳平移而非容器捲動;未保留向後相容 alias
    • MediaInput single mode toolbar 的 Delete / Upload 按鈕由 btn-sm(32×32px)改為 btn-xs(24×24px),圖示 w-4 h-4 → w-3.5 h-3.5
    • MediaInput 的單檔與縮圖刪除按鈕不再固定使用 error / destructive 色彩,改為與 FileInput 等輸入元件一致的中性移除語意;hover 樣式同步採用 toolbar 的 ghost 控制鈕規格
    • 下載能力由 MediaInput 遷移至 MediaViewer:新增預設開啟的 downloadable 與 downloadAllFileName,single/carousel 下載目前項目、thumbnail 打包全部項目;下載按鈕維持 24×24px 中性 ghost 樣式與 bottom-4 right-4 定位,進度/成功/失敗狀態改由 viewer 左上角疊層呈現。MediaInput 僅轉交設定並沿用相同行為
    • fullscreenable 預設值由 false 改為 true;全螢幕控制由右上 toolbar 收入 Settings 展開選單,固定插在 Settings 與 Rotate Counter-clockwise 的中點,不參與既有扇形操作的角度重分配;縮圖/輪播模式也可透過該選單進入全螢幕,F 快捷鍵維持不變
    • MediaViewer 的 mediaViewerControlButtonVariants 基底 btn-sm → btn-xs,影響所有控制按鈕(Zoom In/Out、Rotate CW/CCW、Reset、Expand/Collapse Controls、Page Navigation、Play/Pause、View Mode Toggle、Fullscreen Toggle),對應圖示 w-4 h-4 → w-3.5 h-3.5
    • 統一規格:所有 MediaInput 與 MediaViewer 工具按鈕現為 24×24px,與 SortableThumbnail 縮圖刪除鈕(已是 24px)以及 MUI IconButton sizeSmall(24px)一致
    • 規避方式:若應用層需要保留原本 32px 觸控目標,可透過 className 覆蓋傳入自訂尺寸
  • MediaViewer viewMode 被 ResizeObserver 覆蓋 bug:

    • 症狀:multiple 模式下使用者點 toggle 切換到 Show Single 後會「閃一下又切回 thumbnail」
    • 根因:響應式 displayMode 的 useEffect 在 deps 中包含 setViewMode,而 setViewMode 的 useCallback deps 含 viewMode,導致使用者每次 toggle 後 setViewMode reference 變更 → effect 重跑 → 重設 ResizeObserver → 首次 fire 立刻執行 else { setViewMode(initialDisplayMode) } 分支,把剛切到的 single 強制改回 thumbnail
    • 修法:用 constrainedRef 追蹤前一次的「受限狀態」(isSmall || items.length <= 1),只在條件轉換時才動作。初次掛載與 effect 重跑都不主動覆寫使用者選擇——使用者透過 toggle 按鈕的選擇被保留
  • MediaViewer disabled 不阻擋 wheel zoom / mouse drag bug:

    • 症狀:MediaInput disabled 時,圖片仍可用滑鼠滾輪或 macOS trackpad 兩指 pinch 手勢(觸發 wheel + ctrlKey)縮放,亦可拖曳平移
    • 根因:MediaViewer.handleWheel / handleMouseDown 未檢查 disabled prop(僅 handleKeyDown 有檢查)
    • 修法:兩個 handler 開頭加 if (disabled) return,與鍵盤一致;readOnly 不影響檢視操作(縮放/平移為檢視輔助而非編輯動作),維持原行為
  • MediaInput / MediaViewer toolbar 視覺統一:

    • 修正 Reset 與 Rotate Counter-clockwise 共用同一個 RotateCcw 圖示無法分辨的問題:Reset 改用 lucide RefreshCw(完整圓圈雙箭頭,與單向 RotateCw / RotateCcw 半圓箭頭明顯不同)
    • 三邊 toolbar 按鈕 hover 風格統一:
      • 先前 MediaInput Upload(bg-base-100/90 backdrop-blur-sm shadow-lg、無顯式 hover)、MediaViewer ghost variant(bg-base-100/50 hover:bg-base-100/80、無 shadow、無 backdrop blur)、MediaInput Delete(btn-error、無 shadow)三者背景 / shadow / hover 三維度全不一致
      • 現統一為 bg-base-100/90 backdrop-blur-sm shadow-md hover:bg-base-100(Upload / MediaViewer ghost)、btn-error shadow-md hover:bg-error(Delete 維持紅色 destructive 語意但補 shadow + hover)
      • mediaViewerControlButtonVariants.solid variant 也補 shadow-md 保持一致
    • 修正 ghost variant hover 時白底白字看不見 bug:daisyUI 在 btn-ghost hover 時會把文字色還原為 btn-primary 的 primary-content(白色),與本次強制保持白色背景衝突;於 ghost variant className 補 text-base-content/70 hover:text-base-content 強制覆蓋——ghost variant 上 color prop 對視覺無效果,符合「toolbar overlay 應該中性」的設計直覺
  • MediaInput / MediaViewer single 模式 image padding 與 multiple 縮圖 gap:

    • Single mode:renderMedia 外層包 <div className="w-full h-full p-2">(8px 內距),避免圖片貼邊框且與 floating label 視覺接觸
    • Multiple mode:renderThumbnailContainer 縮圖網格 gap-4 p-4(16px)→ gap-2 p-2(8px),縮圖間距更緊湊、與 single mode 視覺一致
  • MediaInput JSX 結構對齊 Input / Textarea / Select:

    • bordered variant 外層 wrapper 新增 pt-3(對齊 Input/Textarea/Select 的浮動 label buffer 慣例);先前外層無 buffer,浮動 label 渲染於外層上緣 −10 ~ −12px 處,在 grid items-start 並排其他 bordered Data Input 時,label 會「凸出 grid row」造成視覺不對齊
    • dc.container(h-[200|280px])由原本套在最外層 wrapper 改為套在內層新增的 positioning wrapper <div className="relative">;fieldset notch / floating label 改為此內層的絕對定位子,外層改為 auto height
    • error / helperText / validation errors 改為內層 positioning wrapper 的 sibling(在外層 normal flow 中),不再被 fixed-height 截斷溢出
    • 視覺影響:bordered variant 總高度增加約 12px(pt-3 buffer)+ helperText 高度;先前 helperText 溢出外層的「鬼影」現象消失,並列其他 bordered Data Input 時 label 對齊到同一 grid row top
    • 規避方式:若應用層需保留原本「無 pt-3 + helperText 溢出」的視覺效果,可在外層自行加 mt-[-12px] 抵消,但不建議——原本的溢出行為實為 bug
  • createHttpClient 檔案上傳 binary-separate(fileUpload.strategy: 'binary' + endpoint)的 PUT 階段未帶 Authorization,對非 presigned(內部)上傳 URL 一律 401:

    • 症狀:prepare(POST {endpoint})正常回傳上傳 URL,但隨後上傳二進位的 PUT 對需 Bearer 認證的內部上傳 URL 回 401 Unauthorized(每次必現、非偶發)
    • 根因:PUT 走原生 fetch,其 Authorization 取自 config.headers.Authorization;但附 Bearer 的 request 攔截器「註冊在最前、axios 反序執行故最後跑」,而 handleFileUpload(執行 PUT 處)先跑——PUT 當下該欄位尚未填值,條件永不成立
    • 修法:PUT 的 token 改與 Bearer 攔截器同源,直接取 auth.getAccessToken(),與攔截器時序無關;presigned URL 行為不變(仍 credentials: 'omit'、不附 Authorization),並保留對外部預設 config.headers.Authorization 的後備判斷(向後相容)
  • createHttpClient 檔案上傳 binary-separate 的 PUT 對非 presigned(自家 API)上傳 URL 未套用 baseURL,反向代理子路徑部署一律上傳失敗:

    • 症狀:app 部署在反向代理子路徑下(如前端 …/tqf/office、API …/tqf/{app}-server)時,帶附件的表單送不出——prepare(POST {endpoint})成功,但上傳二進位的 PUT 從未到達後端(反向代理回 404)。app 在本機執行(無子路徑前綴)時正常,故僅在部署環境現形
    • 根因:prepare 回傳的上傳 URL 是相對 API 根的路徑(如 /api/v1/staging/files/{tempId},不含子路徑前綴)。PUT 走原生 fetch(url)、未套用 client baseURL,瀏覽器改以「頁面 origin」解析相對 URL → 掉了 baseURL 承載的子路徑前綴 → 指向不存在的位址。prepare 走 axios(有套 baseURL)故正常,兩者不一致
    • 修法:依「絕對 vs 相對 URL」二分決定傳輸——相對 URL(自家 API)改走 axios instance(instance.put),與其他 API 呼叫一視同仁:自動套 baseURL(補回子路徑前綴)、由 request 攔截器附 Bearer、由 response 攔截器統一處理 401 refresh 與錯誤轉 ErrorResponse;絕對 URL(presigned,S3/Azure/GCS 等第三方)維持原生 fetch 中性傳輸(不套 baseURL、credentials: 'omit'、不附自家 Bearer,避免洩漏憑證給第三方)。handleFileUpload 攔截器新增「body 本身為 File/Blob 即原樣放行」守衛,作為自家 PUT 重入的遞迴防護
    • 行為變更(自家 API 上傳):PUT 失敗現由 response 攔截器轉為 ErrorResponse(原為通用 Error),並納入 401 自動 refresh 重試——與其他 API 呼叫一致
  • DataTable / VirtualTable:striped={false} 時 sticky 欄 hover 底色比整列深一階:

    • 症狀:關閉斑馬紋的表格,滑鼠移到列上時最左的 sticky 欄(選取欄/序號欄/stickyFirstColumn) 明顯比同列其他欄深,像多了一塊色塊;開啟斑馬紋時正常
    • 根因:sticky 欄的 <th> 有不透明底色(table-pin-cols 給 base-100、斑馬偶數列給 base-200), 會蓋住套在 <tr> 上的 hover 色,故 sticky cell 需自行補色;但補色寫死 group-hover:bg-base-300, 只對齊了 DaisyUI row-hover 的斑馬規則——非斑馬表的 hover 實為 base-200 (實測 light 主題:oklch(0.94) vs oklch(0.97))
    • 修法:補色改由 getStickyHoverBgClass(striped) 依斑馬紋分流(斑馬 base-300/非斑馬 base-200), DataTable 與 VirtualTable 共用 shared/table.config.ts 的同一來源

Security​

  • Content 類型檢測(FileInput、MediaInput)
  • 檔案大小限制
  • isExternalUrl/needsHttpClientFetch 改以實際請求目的地判斷是否附 token(utils/url,FU-108): 先前只把 scheme:// 與 // 開頭視為絕對 URL,/\evil.com、\\evil.com 或夾 tab/換行的寫法被當成 自家 API 路徑。httpClient 沒有 baseURL 或 baseURL 為 / 時,瀏覽器會把它們解析成其他來源, AuthImage、MediaViewer、PdfViewer、FileDownload 等元件因而把 Bearer token 送到該來源。 現在依 axios 的 baseURL 接合規則組出請求 URL,再以瀏覽器 URL 解析比對 origin;無法解析的 URL 視為外部。 binary 上傳流程的上傳目標同樣判斷,指向其他來源時改用不帶 Bearer 的原生 fetch
    • Impact:一般相對路徑與指向 API 來源的絕對 URL 行為不變。先前無法解析的 URL 會當成自家 API 並附 token,現在改為不附
  • httpClient 只對 API origin 附 Bearer token(utils/http):auth 攔截器先前對每個請求都附上 Authorization: Bearer …,防止外洩完全依賴呼叫端先以 needsHttpClientFetch() 分流。現在攔截器依 axios 的 baseURL 接合規則與瀏覽器 URL 解析判斷實際目的地,只有 origin(協定 + 主機 + port)等於 baseURL origin(無 baseURL 時為頁面來源)才附 token、才做主動 refresh;其他 origin 回 401 時也不再 refresh 後 帶 token 重送。呼叫端明確設定的 Authorization header 不受影響
    • Impact:以同一個 httpClient 呼叫其他主機、且依賴自動附上 session token 的程式會改為不帶 token。 連接其他後端時,依單一 client 紀律在 api-client.ts 另建該後端的 client(設定其 baseURL)

Breaking Changes​

  • HTTP 相容政策改由消費端開發模式自動選擇(19.0.0-alpha.138)

    • Impact: 開發模式的非安全 HTTP 呼叫不再需要明示 browserCompatibility;非開發模式即使保留 alpha.137 的 development-http 宣告也不再降級。安全來源及既有 crossTab 策略保持原機制。
    • Migration: 移除專用 personalDevelopment/allowDevelopmentHttp 旗標及 browserCompatibility 組裝,沿用一般開發啟動與正式建置。現有 strict 覆寫可保留;無法判定模式時使用安全來源。
  • Session 瀏覽器能力錯誤改為本機 BrowserCapabilityError(19.0.0-alpha.137)

    • Impact: Web Locks、亂數或 storage 能力不足不再以 session-coordination-unavailable/503 ErrorResponse 表示。只檢查舊 errorCode、status 或 instanceof ErrorResponse 的下游處理不會 捕捉新能力錯誤;未檢查能力就直接呼叫原生 UUID 所產生的 TypeError 也改為此契約。 storage metadata 損壞仍為既有協調狀態錯誤,不應一併改判為瀏覽器能力不足。

    • Migration: 改辨識 BrowserCapabilityError;序列化後以 errorCode='browser-capability-unavailable' 判斷,再依 capability 分流 web-locks/secure-random/persistent-storage/http-compatibility-policy。 新錯誤不帶 HTTP status,使用 getApiErrorMessage(error) 取得可顯示說明。

      import { BrowserCapabilityError, ErrorResponse, getApiErrorMessage } from '@appfuse/appfuse-web/utils'

      // Before:只辨識舊協調錯誤(其中包含能力不足)。
      if (error instanceof ErrorResponse && error.status === 503 && error.errorCode === 'session-coordination-unavailable') {
      console.error(getApiErrorMessage(error))
      }

      // After:瀏覽器能力錯誤有獨立類型及原因,沒有 HTTP status。
      if (error instanceof BrowserCapabilityError) {
      console.error(error.capability, getApiErrorMessage(error))
      }
  • Modal 更名為 Dialog,原 Dialog 更名為 PromptDialog(命名決策見 ADR-010)

    • Impact: Modal / ModalProps / ModalSize 不再匯出,既有 <Modal> 用法出現 TypeScript 編譯錯誤。原 Dialog / DialogProps / DialogVariant 亦不再以該名匯出——但 fleet 盤點 顯示無任何下游直接匯入該元件,實務上僅 Modal 的更名有影響。兩支的 props 幾乎不重疊 (原 Dialog 必要 content / severity,新 Dialog 必要 children),故不存在 「編譯得過但渲染成別的元件」的靜默失敗。MessageDialog / MessageToast / prompt 名稱與行為皆不變。
    • Migration: 機械替換即可。 自 @appfuse/appfuse-web/components 匯入的 Modal → Dialog; <Modal … > → <Dialog … >;ModalProps / ModalSize → DialogProps / DialogSize。 若直接使用過原 Dialog,改為 PromptDialog(DialogProps → PromptDialogProps、 DialogVariant → PromptDialogVariant)。注意同檔若另有 import { Dialog } from '@headlessui/react'(手刻外殼的遺留)會撞名——該類檔案本就 應改用框架元件,或將 Headless UI 的匯入別名為 HeadlessDialog。
  • DatePicker/DateTimePicker 現以必填 type 形成辨識式 union,且不再寬容跨語意值型別

    • Impact: 既有呼叫未傳 type、Local 模式傳入 Date、或 DayStatus.date 傳入 Date 時會出現 TypeScript 編譯錯誤;DatePicker type="instant" 傳入非法鐘面字面值,或 form wrapper 的 name 指向不相符值型別時也會編譯失敗。未提供 instantTime 則採 start-of-day。JS/as any 造成的 form 值錯配會顯示欄位 inline contract error,且保留原始表單值供修正或診斷。
    • Migration: LocalDate 使用 DatePicker type="local-date" 與 YYYY-MM-DD 字串;LocalDateTime 使用 DateTimePicker type="local-date-time" 與無 offset 字串;Instant 使用對應 picker 的 type="instant" 與 Date。只有日期的 Instant 另以 instantTime="start-of-day"、 "end-of-day" 或明確鐘面時間宣告補時策略。
  • AuthConfig.refresh() 的 null 契約收緊為「Server 明確拒絕 refresh credential」;網路、timeout、 5xx 等基礎設施失敗必須 throw,不得 catch 後回 null

    • Impact: 舊實作若以 catch { return null } 吞掉 transport/5xx,升級後仍會把 Server 異常誤判為 Session Expired,且不會有編譯期錯誤提示
    • Migration: 僅在可辨識的 refresh credential 拒絕(例如 ErrorResponse.status === 401)時回 null;其他錯誤原樣 throw,交由 createHttpClient 發布 Server availability 事件
  • 下游自有的 refresh classifier/跨分頁 Web Lock rollout shim 與新版 framework coordinator 衝突

    • Impact: 若 app callback 在 framework 已取得 appfuse:refresh-session 後再次要求同名 exclusive lock,Web Locks 不可重入,refresh 將永久等待且不發布重新認證或 availability 事件
    • Migration: 升級時移除 app-owned classifier、refresh lock 與 callback timeout shim。無法原子移除 時,shim 必須先以 'requiresReauthenticationAfterRefresh' in appfuseUtils feature detection 自動 停用三者,讓新版 framework 成為唯一 refresh lifecycle policy owner
  • DataTable / VirtualTable 的欄位排序改到齒輪「欄位」面板操作,表頭不再有拖曳把手;欄位順序一併改為純非受控

    • UI 變更:表頭移除拖曳把手(⋮⋮),只剩排序 + 欄名。欄位的顯示 / 隱藏與排序統一收在齒輪的「欄位」面板——每列一個拖曳把手 + 勾選,拖動列重排、點列切換顯示(支援鍵盤:把手 focus → 空白鍵拾起 → 上下移動 → 空白鍵放下)
    • API 變更:
      • 移除 enableColumnReordering prop——排序是「欄位」面板的常駐功能,沒有東西要 gate
      • 移除受控 columnOrder prop,改為 defaultColumnOrder(種子;只在首次渲染取值)
    • 影響範圍:以 columnOrder 受控驅動、或以 enableColumnReordering 開關欄位拖拽的下游
    • Impact: 編譯期型別錯誤(columnOrder / enableColumnReordering prop 不存在);倚賴「表頭拖曳」操作的使用者改用「欄位」面板
    • Migration:
      • enableColumnReordering → 移除(排序恆在「欄位」面板可用)
      • 受控 columnOrder={x} → defaultColumnOrder={x}(種子),onColumnOrderChange 保留(非受控下仍會觸發,可持久化;Redux 場景照舊 dispatch)
      • 程式化驅動欄序(切換檢視預設集)→ 用 onTableChange 取得的 table 實例呼叫 table.setColumnOrder(...)
  • DataTable / VirtualTable 的欄位可見性改為純非受控:移除受控 columnVisibility prop,改為 defaultColumnVisibility(種子;只在首次渲染取值)

    • 影響範圍:以 columnVisibility 受控驅動欄位顯示 / 隱藏的下游
    • Impact: 編譯期型別錯誤(columnVisibility prop 不存在);VirtualTable 先前更是恆受控卻無未控 fallback,未同時傳 columnVisibility + onColumnVisibilityChange 時齒輪隱藏欄完全無效(本次一併修正)
    • Migration:
      • 只想讓使用者自由切欄位 → 移除 columnVisibility prop(零 props 即可運作)
      • 需持久化 → 保留 onColumnVisibilityChange(非受控下仍會觸發),初始值改用 defaultColumnVisibility(例:Redux 場景 columnVisibility={x} → defaultColumnVisibility={x},callback 不變)
      • 需初始隱藏某欄 → defaultColumnVisibility={{ colId: false }}
      • 需程式化驅動(切換檢視預設集)→ 用 onTableChange 取得的 table 實例呼叫 table.setColumnVisibility(...)
      • 逐欄禁止隱藏 → column 的 enableHiding: false(不變)
  • RichTextEditor 公開 API 由 Slate Descendant[] 改為 HTML 字串

    • 影響範圍:使用 <RichTextEditor>(base 元件或 form 包裝)並透過 value / defaultValue / onChange 操作內容的下游 applet
    • Impact: 編譯期型別錯誤(Descendant[] → string),執行期既有資料若仍為 Slate JSON 字串會以原樣顯示為文字
    • Migration:
      • value: Descendant[] | null → value: string | null(HTML,如 '<p>Hello <strong>world</strong></p>')
      • onChange: (value: Descendant[]) => void → onChange: (value: string) => void
      • 移除 import type { Descendant } from 'slate'
      • 移除 EMPTY: Descendant[] 引用,改為 EMPTY: '<p></p>'(從 @appfuse/appfuse-web/components re-export)
      • Form 層:react-hook-form 的 defaultValues 由 '' / Slate JSON 字串改為 HTML 字串,欄位值序列化也直接是 HTML
      • 進階用途(取 Editor 實例):ref.current?.getEditor() 回傳的型別由 Slate Editor 改為 Tiptap Editor,commands API 完全不同
    • 設計取捨:HTML 為跨工具事實標準(後端 sanitize、Email/PDF 模板、SSR、全文搜尋皆受惠),且未來若改換底層引擎不必再破 API。需要 ProseMirror JSON 的進階場景仍可透過 ref.current?.getEditor()?.getJSON() 取得