@cahyo-dimas/freeday 1.32.1 → 1.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,57 @@
3
3
  Semua perubahan penting dicatat di sini. Format longgar mengikuti
4
4
  [Keep a Changelog](https://keepachangelog.com/); tiap versi = git tag.
5
5
 
6
+ ## [1.33.0] — 2026-08-18
7
+ Three notes from the back-office app, four findings, all four executed.
8
+ ### Added
9
+ - **`pager={false}` on `FdyTable`** (Vue · React · Blazor). Server mode rendered its own footer
10
+ unconditionally: `hasPager` was `pageSize > 0 && totalPages > 1` with no way in, so a responsive
11
+ list — a table at `lg`, `.fdy-list` below it, one pager serving both so the views cannot disagree —
12
+ showed the kit's footer *and* the app's, stacked, on every list with more than one page. The only
13
+ workaround was `display:none` on `.fdy-table-footer`, an app reaching into a component's internals.
14
+ Client mode already had its hatch (`pageIndex`); this gives server mode one, and in server mode it
15
+ is the honest shape — the app owns the page there anyway, it was simply being handed a second
16
+ control.
17
+ - **`usePopover` is exported from the Vue adapter.** Every kit dropdown uses it to escape an
18
+ ancestor's overflow clip (`.fdy-card` is `overflow:hidden`, so a panel inside one is cut at the
19
+ card's edge), and a consuming app that had to build a control the kit does not ship had to
20
+ re-implement it — ~70 lines, from the kit's own description, that will not improve when the kit's
21
+ positioning does. *Correction to the report: React has exported it all along; only Vue was missing
22
+ it. Blazor has no composable layer — its panels go through the JS bridge.*
23
+ - **Quiet destructive buttons.** `--danger` was the solid treatment and nothing else, so
24
+ `fdy-btn--ghost fdy-btn--danger` — which every reader parses as "quiet destructive" — rendered a
25
+ second SOLID button. That is a hierarchy defect: the base class is already the one primary per
26
+ screen, and a solid Delete beside Save is a second primary in all but name.
27
+ `.fdy-menu__item--danger` has always modelled the quiet form; it now reaches `.fdy-btn`.
28
+ ### Fixed
29
+ - **`.fdy-eyebrow` and `.fdy-nav__grouplabel` spend a text ink.** Both set type in
30
+ `--color-text-subtle`, which is gated at 3.0 *by design* (placeholders, separators, decorative
31
+ glyphs) and measures 4.41:1 — axe reports it as a serious `color-contrast` failure. Identical to
32
+ `.fdy-stat__label` in 1.30.0, one release earlier: same ink, same 4.41, same argument.
33
+ The nav group label is the one the report did not know about — the sweep it asked for found it,
34
+ and that rule was *already* brightening to `--color-text-muted` on hover, which is the CSS
35
+ admitting its resting state was too faint.
36
+ ### Notes on the shape of the fix
37
+ - The report proposed scoping the solid danger with `:not(.fdy-btn--ghost):not(.fdy-btn--text)`.
38
+ Shipped without it: the pairing rules are more specific and come later, so they already win —
39
+ the `:not()` would be a second mechanism expressing the same intent, and the mutation run proved
40
+ it changed nothing.
41
+ - **A regression written and caught inside this change:** Vue casts an omitted Boolean prop to
42
+ `false`, not `undefined`, so `props.pager !== false` withheld the footer from every table that
43
+ never mentions `pager`. The existing controlled-`pageIndex` guard failed within seconds. `pager`
44
+ now goes through `withDefaults`, the same trap `FdyModal`'s `dismissible` hit in v1.18.0.
45
+ ### Added — guards
46
+ - **The prose-ink invariant** note 007 asked for: any rule that sets a `font-size` *and* spends
47
+ `--color-text-subtle` is typesetting prose in an ink gated for decoration, and fails. Matched by
48
+ shape, not by a list of names, so the third instance cannot arrive the way the second did. Two
49
+ documented exemptions carry their reason.
50
+ - `pager={false}` is measured in a real browser with two tables sharing one server page state: the
51
+ footer must be **absent from the DOM**, not hidden (a visually hidden pager is still a tab stop
52
+ and still announced), and the other table's pager must still work.
53
+ - Quiet destructive is measured as a rendered `background-image`, the same reason the `aria-pressed`
54
+ fills are: a gradient is a background-*image*, so the CSS reads fine either way and only the engine
55
+ shows which treatment won.
56
+
6
57
  ## [1.32.1] — 2026-08-18
7
58
  Docs only. Asked by a consumer: *"if I upgrade, will the agent in my project know what it gained?"*
8
59
  The answer was no, and two reasons why.
package/COMPONENTS.md CHANGED
@@ -259,6 +259,11 @@ a compact root with one region opted back out (shared chrome that must match a s
259
259
  </button>
260
260
  ```
261
261
 
262
+ **Quiet destructive:** combine `--danger` with `--ghost` or `--text`. The ground stays quiet and only
263
+ the ink turns red — `.fdy-menu__item--danger` has always worked this way. Use it whenever Delete
264
+ shares a screen with the one primary action; a solid Delete beside Save is a second primary in all
265
+ but name.
266
+
262
267
  ## FAB — `.fdy-fab`
263
268
  Floating circular action. Modifiers: `--sm` `--accent` `--danger` `--extended` (pill with a label).
264
269
  Always `aria-label` unless `--extended` carries visible text.
@@ -717,6 +722,18 @@ accessible name (`Filter <column>`) — a dialog named after its trigger is the
717
722
  Playwright/Testing-Library suite that means `getByLabel('Filter Name')` resolves to two elements;
718
723
  reach for `getByRole('button', { name: 'Filter Name' })` instead.
719
724
 
725
+ **Who draws the pager.** The table renders its own footer (range + pager) whenever there is more
726
+ than one page. Two ways to take it over:
727
+
728
+ - **client mode** — pass `pageIndex` (with `pageSize`, without `page`) and drive it yourself;
729
+ - **either mode** — pass `pager={false}` (Vue `:pager="false"`, Blazor `Pager="false"`) and render
730
+ your own control. In server mode this is the only way: the app already owns the page there, and
731
+ was still being handed a second control. The typical shape is a responsive list — a table at `lg`,
732
+ `.fdy-list` below it — where one pager has to serve both views so they cannot disagree.
733
+
734
+ Do not hide the footer with CSS. `display:none` works, but it reaches into a component's internals
735
+ and breaks the moment the class changes.
736
+
720
737
  ## Pagination — `.fdy-pagination`
721
738
  The block class on the `<nav>` is a **structural hook only** — it carries no rule of its own; the
722
739
  `__list` / `__link` / `__ellipsis` elements do all the styling, and the data table targets
package/README.id.md CHANGED
@@ -5,7 +5,7 @@
5
5
  > **Lebih banyak _free day_ buat dev — UI kit-nya sudah siap pakai.**
6
6
 
7
7
  [![Live docs](https://img.shields.io/badge/docs-live-2050d8?style=flat-square)](https://cahyo-dimas.github.io/freeday-ui-kit/)
8
- [![Release](https://img.shields.io/badge/release-v1.32.1-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.32.1)
8
+ [![Release](https://img.shields.io/badge/release-v1.33.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.33.0)
9
9
 
10
10
  UI KIT yang token-driven & framework-agnostic — satu sumber kebenaran untuk warna, tipografi,
11
11
  spasi, dan komponen. Blueprint: `docs/superpowers/specs/2026-07-21-freeday-ui-kit-design.md`.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  > **More free days for devs — the UI kit is ready to use.**
6
6
 
7
7
  [![Live docs](https://img.shields.io/badge/docs-live-2050d8?style=flat-square)](https://cahyo-dimas.github.io/freeday-ui-kit/)
8
- [![Release](https://img.shields.io/badge/release-v1.32.1-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.32.1)
8
+ [![Release](https://img.shields.io/badge/release-v1.33.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.33.0)
9
9
 
10
10
  A token-driven, framework-agnostic UI kit — one source of truth for color, typography,
11
11
  spacing, and components. Blueprint: `docs/superpowers/specs/2026-07-21-freeday-ui-kit-design.md`.
@@ -36,6 +36,11 @@ public partial class FdyTable<TRow>
36
36
  /// the <c>md</c> breakpoint and renders a card list from <see cref="Process"/> can render one pager
37
37
  /// for both breakpoints and bind it here. Leave null for the internal index (unchanged default).
38
38
  /// </summary>
39
+ /// <summary>Withhold the table's own footer (pager + range) so the screen can render one.
40
+ /// Server mode had no way to do this: the app owns the page there anyway, and was still handed a
41
+ /// second control. Client mode's counterpart is <see cref="PageIndex"/>. Default true.</summary>
42
+ [Parameter] public bool Pager { get; set; } = true;
43
+
39
44
  [Parameter] public int? PageIndex { get; set; }
40
45
 
41
46
  /// <summary>Raised in client mode with <see cref="PageIndex"/> set: the table asks for a new
@@ -97,7 +102,7 @@ public partial class FdyTable<TRow>
97
102
  private int CurrentPage1 => (ServerPaged ? Page!.Index : ClientPageIndex) + 1;
98
103
  private int TotalCount => _totalCount;
99
104
  private int TotalPages => PageSizeEff > 0 ? Math.Max(1, (int)Math.Ceiling((double)TotalCount / PageSizeEff)) : 1;
100
- private bool HasPager => PageSizeEff > 0 && TotalPages > 1;
105
+ private bool HasPager => Pager && PageSizeEff > 0 && TotalPages > 1;
101
106
  private List<int?> Pages => TableModel.PageWindow(CurrentPage1, TotalPages);
102
107
  private int RangeFrom => TotalCount == 0 ? 0 : (CurrentPage1 - 1) * PageSizeEff + 1;
103
108
  private int RangeTo => TotalCount == 0 ? 0 : RangeFrom - 1 + _displayRows.Count;
@@ -51,6 +51,11 @@ export interface FdyTableProps<Row extends object> {
51
51
  * card list from `onProcess` can render one pager for both breakpoints and point it here.
52
52
  * Omit for the internal index (unchanged default).
53
53
  */
54
+ /** Withhold the table's own footer (pager + range) so the screen can render one. Server mode had
55
+ * no way to do this: the app owns the page there ANYWAY, and was still handed a second control —
56
+ * a responsive list that shows a table at one breakpoint and cards at another ended up with the
57
+ * kit's pager stacked under its own. Client mode's counterpart is `pageIndex`. Default true. */
58
+ pager?: boolean;
54
59
  pageIndex?: number;
55
60
  /** Client mode with `pageIndex` provided: the table asks for a new 0-based index (pager click, a
56
61
  * reset to 0 after sort/filter, or a clamp when filtering shrank the set). */
@@ -127,7 +132,7 @@ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Ele
127
132
  const pageSizeEff: number = serverPaged ? (props.page as FdyPageState).size : (props.pageSize ?? 0);
128
133
  const currentPage1: number = (serverPaged ? (props.page as FdyPageState).index : clientPageIndex) + 1;
129
134
  const totalPages: number = pageSizeEff > 0 ? Math.max(1, Math.ceil(totalCount / pageSizeEff)) : 1;
130
- const hasPager: boolean = pageSizeEff > 0 && totalPages > 1;
135
+ const hasPager: boolean = props.pager !== false && pageSizeEff > 0 && totalPages > 1;
131
136
  const pages: Array<number | 'ellipsis'> = pageWindow(currentPage1, totalPages);
132
137
  const rangeFrom: number = totalCount === 0 ? 0 : (currentPage1 - 1) * pageSizeEff + 1;
133
138
  const rangeTo: number = totalCount === 0 ? 0 : rangeFrom - 1 + displayRows.length;
@@ -29,7 +29,10 @@ import FdyTableFilter from './FdyTableFilter.vue';
29
29
  // (`update:sort` / `update:filters` / `update:page`) for the caller to feed back into its query.
30
30
  // Column filters (text/enum/number/date) apply live; in server mode, debounce the emit if needed.
31
31
 
32
- const props = defineProps<{
32
+ /* `pager` MUST go through withDefaults: Vue's boolean-cast gives an omitted Boolean prop `false`,
33
+ not `undefined`, so a plain `props.pager !== false` would withhold the footer from every table that
34
+ never mentions it. Same trap as FdyModal's `dismissible`. */
35
+ const props = withDefaults(defineProps<{
33
36
  columns: ReadonlyArray<FdyTableColumn<Row>>;
34
37
  rows: ReadonlyArray<Row>;
35
38
  rowKey: (row: Row) => string | number;
@@ -48,6 +51,11 @@ const props = defineProps<{
48
51
  * card list from the `process` event can render one pager for both breakpoints and point it here.
49
52
  * Omit for the internal index (unchanged default).
50
53
  */
54
+ /** Withhold the table's own footer (pager + range) so the screen can render one. Server mode had
55
+ * no way to do this: the app owns the page there ANYWAY, and was still handed a second control —
56
+ * a responsive list that shows a table at one breakpoint and cards at another ended up with the
57
+ * kit's pager stacked under its own. Client mode's counterpart is `pageIndex`. Default true. */
58
+ pager?: boolean;
51
59
  pageIndex?: number;
52
60
  loading?: boolean;
53
61
  emptyText?: string;
@@ -58,7 +66,7 @@ const props = defineProps<{
58
66
  rowClass?: (row: Row) => string | undefined;
59
67
  /** Controlled: row keys whose `row-detail` slot is shown as a full-width row beneath them. */
60
68
  expandedKeys?: ReadonlyArray<string | number>;
61
- }>();
69
+ }>(), { pager: true });
62
70
 
63
71
  const emit = defineEmits<{
64
72
  'update:sort': [sort: FdySortState | null];
@@ -137,7 +145,7 @@ const currentPage1: ComputedRef<number> = computed((): number =>
137
145
  const totalPages: ComputedRef<number> = computed((): number =>
138
146
  pageSizeEff.value > 0 ? Math.max(1, Math.ceil(totalCount.value / pageSizeEff.value)) : 1,
139
147
  );
140
- const hasPager: ComputedRef<boolean> = computed((): boolean => pageSizeEff.value > 0 && totalPages.value > 1);
148
+ const hasPager: ComputedRef<boolean> = computed((): boolean => props.pager !== false && pageSizeEff.value > 0 && totalPages.value > 1);
141
149
  const pages: ComputedRef<Array<number | 'ellipsis'>> = computed((): Array<number | 'ellipsis'> =>
142
150
  pageWindow(currentPage1.value, totalPages.value),
143
151
  );
@@ -12,6 +12,19 @@ export interface UseFreedayReturn {
12
12
  */
13
13
  export declare function useFreeday(rootRef?: Ref<HTMLElement | null>): UseFreedayReturn;
14
14
 
15
+ /**
16
+ * Anchor a dropdown panel to its trigger in the top layer, so it escapes any ancestor overflow clip
17
+ * — `.fdy-card` is `overflow:hidden`, so a panel positioned inside one is otherwise cut at the card's
18
+ * edge. The panel element must carry `popover="manual"`. This is the plumbing every kit dropdown
19
+ * uses; it is exported so a control the kit does not ship can behave like one that it does, instead
20
+ * of each app re-implementing it from the description.
21
+ */
22
+ export declare function usePopover(
23
+ panelRef: Ref<HTMLElement | null>,
24
+ triggerRef: Ref<HTMLElement | null>,
25
+ open: Ref<boolean>,
26
+ ): void;
27
+
15
28
  export { default as FdyCombo } from './components/FdyCombo.vue';
16
29
  export { default as FdyDatepicker } from './components/FdyDatepicker.vue';
17
30
  export { default as FdyDateRange } from './components/FdyDateRange.vue';
@@ -1,4 +1,5 @@
1
1
  export { useFreeday } from './useFreeday.js';
2
+ export { usePopover } from './usePopover.ts';
2
3
  export { default as FdyCombo } from './components/FdyCombo.vue';
3
4
  export { default as FdyDatepicker } from './components/FdyDatepicker.vue';
4
5
  export { default as FdyDateRange } from './components/FdyDateRange.vue';
@@ -510,7 +510,11 @@ a { color: var(--color-primary); }
510
510
  /* Collapsible section: <details class="fdy-nav__group"><summary class="fdy-nav__grouplabel"> */
511
511
  .fdy-nav__group{margin:0;}
512
512
  .fdy-nav__group + .fdy-nav__group,.fdy-nav__item + .fdy-nav__group{border-top:var(--bw) solid var(--color-border-muted);margin-top:var(--space-1);padding-top:var(--space-1);}
513
- .fdy-nav__grouplabel{list-style:none;cursor:pointer;display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);padding:var(--space-3) var(--space-3) var(--space-1);font-size:var(--text-xs);text-transform:uppercase;letter-spacing:var(--tracking-wide);font-weight:var(--weight-semibold);color:var(--color-text-subtle);}
513
+ /* Prose, and a control name: this is the <summary> of a nav group, at --text-xs uppercase. It is
514
+ the second class found spending the decorative ink on type (after .fdy-eyebrow) — and it was
515
+ already brightening to --color-text-muted on hover, which is the rule admitting its resting
516
+ state was too faint to read. */
517
+ .fdy-nav__grouplabel{list-style:none;cursor:pointer;display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);padding:var(--space-3) var(--space-3) var(--space-1);font-size:var(--text-xs);text-transform:uppercase;letter-spacing:var(--tracking-wide);font-weight:var(--weight-semibold);color:var(--color-text-muted);}
514
518
  .fdy-nav__grouplabel::-webkit-details-marker{display:none;}
515
519
  .fdy-nav__grouplabel:hover{color:var(--color-text-muted);}
516
520
  .fdy-nav__grouplabel:focus-visible{outline:none;border-radius:var(--radius-sm);box-shadow:0 0 0 2px color-mix(in srgb,var(--color-primary) 45%,transparent);}
@@ -702,9 +706,20 @@ a { color: var(--color-primary); }
702
706
  .fdy-btn--ghost:hover{transform:none;background:var(--color-primary-soft);border-color:var(--color-primary-border);box-shadow:var(--shadow-1);}
703
707
  .fdy-btn--ghost:active{transform:translateY(1px);}
704
708
 
709
+ /* --danger used to be the SOLID treatment and nothing else, so `fdy-btn--ghost fdy-btn--danger` —
710
+ which every reader parses as "quiet destructive" — rendered a second SOLID button beside Save.
711
+ That is a hierarchy defect, not a cosmetic one: the base class is already the one primary per
712
+ screen. `.fdy-menu__item--danger` has always modelled the quiet form (red text, not a red block);
713
+ the pairing rules below bring it to the button. They are more specific than this one and come
714
+ after it, so this rule needs no :not() guard of its own — one mechanism, not two. */
705
715
  .fdy-btn--danger{background:linear-gradient(180deg,var(--color-danger-btn),color-mix(in srgb,#000 12%,var(--color-danger-btn)));color:var(--color-on-danger);box-shadow:0 2px 9px -1px color-mix(in srgb,var(--color-danger-btn) 46%,transparent),inset 0 1px 0 rgba(255,255,255,.22);}
706
716
  .fdy-btn--danger:hover{transform:translateY(-1px);box-shadow:0 6px 18px -2px color-mix(in srgb,var(--color-danger-btn) 54%,transparent),inset 0 1px 0 rgba(255,255,255,.22);}
707
717
  .fdy-btn--danger:active{transform:translateY(1px);}
718
+ /* Quiet destructive: the variant keeps its own ground and only the ink turns. */
719
+ .fdy-btn--ghost.fdy-btn--danger{background:transparent;color:var(--color-danger-strong);border-color:color-mix(in srgb,var(--color-danger) 40%,transparent);box-shadow:none;}
720
+ .fdy-btn--ghost.fdy-btn--danger:hover{background:var(--color-danger-soft);border-color:var(--color-danger);color:var(--color-danger-strong);}
721
+ .fdy-btn--text.fdy-btn--danger{background:none;color:var(--color-danger-strong);border-color:transparent;box-shadow:none;}
722
+ .fdy-btn--text.fdy-btn--danger:hover{background:var(--color-danger-soft);color:var(--color-danger-strong);}
708
723
  .fdy-btn--danger[aria-pressed="true"]{background:linear-gradient(180deg,color-mix(in srgb,#000 14%,var(--color-danger-btn)),var(--color-danger-btn));box-shadow:inset 0 2px 6px color-mix(in srgb,#000 24%,transparent);}
709
724
 
710
725
  .fdy-btn--sm{height:calc(var(--control-h) - var(--space-2));padding:0 var(--space-3);font-size:var(--text-xs);border-radius:var(--radius-sm);}
@@ -1032,7 +1047,11 @@ a { color: var(--color-primary); }
1032
1047
  /* ---- Type roles (three title levels + supporting text) -------------------------------------
1033
1048
  * One role per level of hierarchy. Reach for these instead of re-using .fdy-card__title for a
1034
1049
  * page title (the mistake that collapses three levels into one grey mush). */
1035
- .fdy-eyebrow{font-family:var(--font-body);font-size:var(--text-xs);font-weight:var(--weight-semibold);letter-spacing:var(--tracking-wide);text-transform:uppercase;color:var(--color-text-subtle);}
1050
+ /* --color-text-muted, not -subtle: an eyebrow is a small label naming the thing below it, i.e.
1051
+ prose. -subtle is gated at 3.0 on purpose (placeholders, separators, decorative glyphs) and
1052
+ measures 4.41 on the page ground — axe reports it as a serious color-contrast failure. Same
1053
+ argument, same ink, same 4.41 as .fdy-stat__label in 1.30.0. */
1054
+ .fdy-eyebrow{font-family:var(--font-body);font-size:var(--text-xs);font-weight:var(--weight-semibold);letter-spacing:var(--tracking-wide);text-transform:uppercase;color:var(--color-text-muted);}
1036
1055
  .fdy-title-page{font-family:var(--font-display);font-size:var(--text-3xl);font-weight:var(--weight-bold);letter-spacing:var(--tracking-tighter);line-height:var(--leading-tight);color:var(--color-text);margin:0;text-wrap:balance;}
1037
1056
  .fdy-title-section{font-family:var(--font-display);font-size:var(--text-xl);font-weight:var(--weight-semibold);letter-spacing:var(--tracking-tight);line-height:var(--leading-snug);color:var(--color-text);margin:0;text-wrap:balance;}
1038
1057
  .fdy-title-card{font-family:var(--font-display);font-size:var(--text-lg);font-weight:var(--weight-semibold);letter-spacing:var(--tracking-tight);color:var(--color-text);margin:0;}
package/dist/freeday.css CHANGED
@@ -127,7 +127,11 @@ a { color: var(--color-primary); }
127
127
  /* Collapsible section: <details class="fdy-nav__group"><summary class="fdy-nav__grouplabel"> */
128
128
  .fdy-nav__group{margin:0;}
129
129
  .fdy-nav__group + .fdy-nav__group,.fdy-nav__item + .fdy-nav__group{border-top:var(--bw) solid var(--color-border-muted);margin-top:var(--space-1);padding-top:var(--space-1);}
130
- .fdy-nav__grouplabel{list-style:none;cursor:pointer;display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);padding:var(--space-3) var(--space-3) var(--space-1);font-size:var(--text-xs);text-transform:uppercase;letter-spacing:var(--tracking-wide);font-weight:var(--weight-semibold);color:var(--color-text-subtle);}
130
+ /* Prose, and a control name: this is the <summary> of a nav group, at --text-xs uppercase. It is
131
+ the second class found spending the decorative ink on type (after .fdy-eyebrow) — and it was
132
+ already brightening to --color-text-muted on hover, which is the rule admitting its resting
133
+ state was too faint to read. */
134
+ .fdy-nav__grouplabel{list-style:none;cursor:pointer;display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);padding:var(--space-3) var(--space-3) var(--space-1);font-size:var(--text-xs);text-transform:uppercase;letter-spacing:var(--tracking-wide);font-weight:var(--weight-semibold);color:var(--color-text-muted);}
131
135
  .fdy-nav__grouplabel::-webkit-details-marker{display:none;}
132
136
  .fdy-nav__grouplabel:hover{color:var(--color-text-muted);}
133
137
  .fdy-nav__grouplabel:focus-visible{outline:none;border-radius:var(--radius-sm);box-shadow:0 0 0 2px color-mix(in srgb,var(--color-primary) 45%,transparent);}
@@ -319,9 +323,20 @@ a { color: var(--color-primary); }
319
323
  .fdy-btn--ghost:hover{transform:none;background:var(--color-primary-soft);border-color:var(--color-primary-border);box-shadow:var(--shadow-1);}
320
324
  .fdy-btn--ghost:active{transform:translateY(1px);}
321
325
 
326
+ /* --danger used to be the SOLID treatment and nothing else, so `fdy-btn--ghost fdy-btn--danger` —
327
+ which every reader parses as "quiet destructive" — rendered a second SOLID button beside Save.
328
+ That is a hierarchy defect, not a cosmetic one: the base class is already the one primary per
329
+ screen. `.fdy-menu__item--danger` has always modelled the quiet form (red text, not a red block);
330
+ the pairing rules below bring it to the button. They are more specific than this one and come
331
+ after it, so this rule needs no :not() guard of its own — one mechanism, not two. */
322
332
  .fdy-btn--danger{background:linear-gradient(180deg,var(--color-danger-btn),color-mix(in srgb,#000 12%,var(--color-danger-btn)));color:var(--color-on-danger);box-shadow:0 2px 9px -1px color-mix(in srgb,var(--color-danger-btn) 46%,transparent),inset 0 1px 0 rgba(255,255,255,.22);}
323
333
  .fdy-btn--danger:hover{transform:translateY(-1px);box-shadow:0 6px 18px -2px color-mix(in srgb,var(--color-danger-btn) 54%,transparent),inset 0 1px 0 rgba(255,255,255,.22);}
324
334
  .fdy-btn--danger:active{transform:translateY(1px);}
335
+ /* Quiet destructive: the variant keeps its own ground and only the ink turns. */
336
+ .fdy-btn--ghost.fdy-btn--danger{background:transparent;color:var(--color-danger-strong);border-color:color-mix(in srgb,var(--color-danger) 40%,transparent);box-shadow:none;}
337
+ .fdy-btn--ghost.fdy-btn--danger:hover{background:var(--color-danger-soft);border-color:var(--color-danger);color:var(--color-danger-strong);}
338
+ .fdy-btn--text.fdy-btn--danger{background:none;color:var(--color-danger-strong);border-color:transparent;box-shadow:none;}
339
+ .fdy-btn--text.fdy-btn--danger:hover{background:var(--color-danger-soft);color:var(--color-danger-strong);}
325
340
  .fdy-btn--danger[aria-pressed="true"]{background:linear-gradient(180deg,color-mix(in srgb,#000 14%,var(--color-danger-btn)),var(--color-danger-btn));box-shadow:inset 0 2px 6px color-mix(in srgb,#000 24%,transparent);}
326
341
 
327
342
  .fdy-btn--sm{height:calc(var(--control-h) - var(--space-2));padding:0 var(--space-3);font-size:var(--text-xs);border-radius:var(--radius-sm);}
@@ -649,7 +664,11 @@ a { color: var(--color-primary); }
649
664
  /* ---- Type roles (three title levels + supporting text) -------------------------------------
650
665
  * One role per level of hierarchy. Reach for these instead of re-using .fdy-card__title for a
651
666
  * page title (the mistake that collapses three levels into one grey mush). */
652
- .fdy-eyebrow{font-family:var(--font-body);font-size:var(--text-xs);font-weight:var(--weight-semibold);letter-spacing:var(--tracking-wide);text-transform:uppercase;color:var(--color-text-subtle);}
667
+ /* --color-text-muted, not -subtle: an eyebrow is a small label naming the thing below it, i.e.
668
+ prose. -subtle is gated at 3.0 on purpose (placeholders, separators, decorative glyphs) and
669
+ measures 4.41 on the page ground — axe reports it as a serious color-contrast failure. Same
670
+ argument, same ink, same 4.41 as .fdy-stat__label in 1.30.0. */
671
+ .fdy-eyebrow{font-family:var(--font-body);font-size:var(--text-xs);font-weight:var(--weight-semibold);letter-spacing:var(--tracking-wide);text-transform:uppercase;color:var(--color-text-muted);}
653
672
  .fdy-title-page{font-family:var(--font-display);font-size:var(--text-3xl);font-weight:var(--weight-bold);letter-spacing:var(--tracking-tighter);line-height:var(--leading-tight);color:var(--color-text);margin:0;text-wrap:balance;}
654
673
  .fdy-title-section{font-family:var(--font-display);font-size:var(--text-xl);font-weight:var(--weight-semibold);letter-spacing:var(--tracking-tight);line-height:var(--leading-snug);color:var(--color-text);margin:0;text-wrap:balance;}
655
674
  .fdy-title-card{font-family:var(--font-display);font-size:var(--text-lg);font-weight:var(--weight-semibold);letter-spacing:var(--tracking-tight);color:var(--color-text);margin:0;}
@@ -184,7 +184,7 @@ live docs also have a copy button per component.
184
184
  ```bash
185
185
  npm i @cahyo-dimas/freeday
186
186
  ```
187
- Lands in `package.json` as `"@cahyo-dimas/freeday": "^1.32.1"` (public npm package). `dist/` is
187
+ Lands in `package.json` as `"@cahyo-dimas/freeday": "^1.33.0"` (public npm package). `dist/` is
188
188
  committed and published → no build step; `npm ci` runs without auth.
189
189
 
190
190
  ### 2. Import the CSS + enhancers **once** in your entry (`src/main.ts`)
@@ -319,3 +319,17 @@ web-based add-ons (Service Layer + .NET), Freeday + enhancers over JS interop wo
319
319
  *Library recommendations reflect what's common and stable as of 2026; choose based on your
320
320
  project's license and bundle-size budget. Freeday locks you into none of them — everything is
321
321
  optional and replaceable.*
322
+
323
+ ## Building a control the kit does not ship
324
+
325
+ Use the `app-` prefix for its classes, and reuse the kit's plumbing rather than re-deriving it:
326
+
327
+ ```ts
328
+ import { usePopover } from '@cahyo-dimas/freeday/vue'; // or '/react'
329
+ usePopover(panelRef, triggerRef, open); // panel needs popover="manual"
330
+ ```
331
+
332
+ This is what every kit dropdown uses to escape an ancestor's overflow clip — `.fdy-card` is
333
+ `overflow:hidden`, so a panel positioned inside one is otherwise cut at the card's edge. It is
334
+ exported so a control you build behaves like one the kit ships, and improves when the kit's
335
+ positioning does.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cahyo-dimas/freeday",
3
- "version": "1.32.1",
3
+ "version": "1.33.0",
4
4
  "description": "Freeday — token-driven, framework-agnostic UI KIT (design source-of-truth).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -48,7 +48,11 @@
48
48
  /* Collapsible section: <details class="fdy-nav__group"><summary class="fdy-nav__grouplabel"> */
49
49
  .fdy-nav__group{margin:0;}
50
50
  .fdy-nav__group + .fdy-nav__group,.fdy-nav__item + .fdy-nav__group{border-top:var(--bw) solid var(--color-border-muted);margin-top:var(--space-1);padding-top:var(--space-1);}
51
- .fdy-nav__grouplabel{list-style:none;cursor:pointer;display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);padding:var(--space-3) var(--space-3) var(--space-1);font-size:var(--text-xs);text-transform:uppercase;letter-spacing:var(--tracking-wide);font-weight:var(--weight-semibold);color:var(--color-text-subtle);}
51
+ /* Prose, and a control name: this is the <summary> of a nav group, at --text-xs uppercase. It is
52
+ the second class found spending the decorative ink on type (after .fdy-eyebrow) — and it was
53
+ already brightening to --color-text-muted on hover, which is the rule admitting its resting
54
+ state was too faint to read. */
55
+ .fdy-nav__grouplabel{list-style:none;cursor:pointer;display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);padding:var(--space-3) var(--space-3) var(--space-1);font-size:var(--text-xs);text-transform:uppercase;letter-spacing:var(--tracking-wide);font-weight:var(--weight-semibold);color:var(--color-text-muted);}
52
56
  .fdy-nav__grouplabel::-webkit-details-marker{display:none;}
53
57
  .fdy-nav__grouplabel:hover{color:var(--color-text-muted);}
54
58
  .fdy-nav__grouplabel:focus-visible{outline:none;border-radius:var(--radius-sm);box-shadow:0 0 0 2px color-mix(in srgb,var(--color-primary) 45%,transparent);}
@@ -23,9 +23,20 @@
23
23
  .fdy-btn--ghost:hover{transform:none;background:var(--color-primary-soft);border-color:var(--color-primary-border);box-shadow:var(--shadow-1);}
24
24
  .fdy-btn--ghost:active{transform:translateY(1px);}
25
25
 
26
+ /* --danger used to be the SOLID treatment and nothing else, so `fdy-btn--ghost fdy-btn--danger` —
27
+ which every reader parses as "quiet destructive" — rendered a second SOLID button beside Save.
28
+ That is a hierarchy defect, not a cosmetic one: the base class is already the one primary per
29
+ screen. `.fdy-menu__item--danger` has always modelled the quiet form (red text, not a red block);
30
+ the pairing rules below bring it to the button. They are more specific than this one and come
31
+ after it, so this rule needs no :not() guard of its own — one mechanism, not two. */
26
32
  .fdy-btn--danger{background:linear-gradient(180deg,var(--color-danger-btn),color-mix(in srgb,#000 12%,var(--color-danger-btn)));color:var(--color-on-danger);box-shadow:0 2px 9px -1px color-mix(in srgb,var(--color-danger-btn) 46%,transparent),inset 0 1px 0 rgba(255,255,255,.22);}
27
33
  .fdy-btn--danger:hover{transform:translateY(-1px);box-shadow:0 6px 18px -2px color-mix(in srgb,var(--color-danger-btn) 54%,transparent),inset 0 1px 0 rgba(255,255,255,.22);}
28
34
  .fdy-btn--danger:active{transform:translateY(1px);}
35
+ /* Quiet destructive: the variant keeps its own ground and only the ink turns. */
36
+ .fdy-btn--ghost.fdy-btn--danger{background:transparent;color:var(--color-danger-strong);border-color:color-mix(in srgb,var(--color-danger) 40%,transparent);box-shadow:none;}
37
+ .fdy-btn--ghost.fdy-btn--danger:hover{background:var(--color-danger-soft);border-color:var(--color-danger);color:var(--color-danger-strong);}
38
+ .fdy-btn--text.fdy-btn--danger{background:none;color:var(--color-danger-strong);border-color:transparent;box-shadow:none;}
39
+ .fdy-btn--text.fdy-btn--danger:hover{background:var(--color-danger-soft);color:var(--color-danger-strong);}
29
40
  .fdy-btn--danger[aria-pressed="true"]{background:linear-gradient(180deg,color-mix(in srgb,#000 14%,var(--color-danger-btn)),var(--color-danger-btn));box-shadow:inset 0 2px 6px color-mix(in srgb,#000 24%,transparent);}
30
41
 
31
42
  .fdy-btn--sm{height:calc(var(--control-h) - var(--space-2));padding:0 var(--space-3);font-size:var(--text-xs);border-radius:var(--radius-sm);}
@@ -9,7 +9,11 @@
9
9
  /* ---- Type roles (three title levels + supporting text) -------------------------------------
10
10
  * One role per level of hierarchy. Reach for these instead of re-using .fdy-card__title for a
11
11
  * page title (the mistake that collapses three levels into one grey mush). */
12
- .fdy-eyebrow{font-family:var(--font-body);font-size:var(--text-xs);font-weight:var(--weight-semibold);letter-spacing:var(--tracking-wide);text-transform:uppercase;color:var(--color-text-subtle);}
12
+ /* --color-text-muted, not -subtle: an eyebrow is a small label naming the thing below it, i.e.
13
+ prose. -subtle is gated at 3.0 on purpose (placeholders, separators, decorative glyphs) and
14
+ measures 4.41 on the page ground — axe reports it as a serious color-contrast failure. Same
15
+ argument, same ink, same 4.41 as .fdy-stat__label in 1.30.0. */
16
+ .fdy-eyebrow{font-family:var(--font-body);font-size:var(--text-xs);font-weight:var(--weight-semibold);letter-spacing:var(--tracking-wide);text-transform:uppercase;color:var(--color-text-muted);}
13
17
  .fdy-title-page{font-family:var(--font-display);font-size:var(--text-3xl);font-weight:var(--weight-bold);letter-spacing:var(--tracking-tighter);line-height:var(--leading-tight);color:var(--color-text);margin:0;text-wrap:balance;}
14
18
  .fdy-title-section{font-family:var(--font-display);font-size:var(--text-xl);font-weight:var(--weight-semibold);letter-spacing:var(--tracking-tight);line-height:var(--leading-snug);color:var(--color-text);margin:0;text-wrap:balance;}
15
19
  .fdy-title-card{font-family:var(--font-display);font-size:var(--text-lg);font-weight:var(--weight-semibold);letter-spacing:var(--tracking-tight);color:var(--color-text);margin:0;}