diff --git a/CONTINUE.md b/CONTINUE.md
new file mode 100644
index 000000000..afbc75c37
--- /dev/null
+++ b/CONTINUE.md
@@ -0,0 +1,65 @@
+# CONTINUE — hand-off (2026-06-26 → next session)
+
+Branch `active-uix`. This session's work is **committed + pushed**. Pick up the
+**DateTimePicker refactor** next.
+
+## Next task — shared "time-in-popover" for DatePicker + DateRangePicker
+
+Full plan: `C:\Users\dev\.claude\plans\woolly-knitting-fairy.md` (approved).
+Decisions locked with the user: composable `` part (not
+auto-rendered) · DateRangePicker shows **start + end** (two time pickers).
+
+Goal: lift the ad-hoc time editor out of the DatePicker **demo** into a
+**shared, reusable mechanism**, then give DateRangePicker the same. DatePicker
+becomes effectively a DateTimePicker (granularity reaching `hour|minute|second`
+surfaces the time editor); the **hour cycle derives from `locale`** when
+`hourCycle` is unset (en-US → 12h, es-ES → 24h), for input **and** time editor.
+
+Both soma providers already plumb `granularity` + `hourCycle`; the missing piece
+is the popover time editor (today hand-composed in the date-picker demo).
+
+Execution order (see plan §1–6):
+1. `picker-shell/picker-time.ts` (new) — pure helpers: `localeHourCycle`,
+ `granularityHasTime`, `timeOf`, `withTimeOn`, `withoutTime` (reuse `$libs/days`).
+2. `picker-shell/picker-time-row.svelte` (new) — value-agnostic time row (knows
+ only `TimeValue`); self-hides when granularity has no time; composes the
+ `` namespace; owns the relocated time-row CSS.
+3. Both `*-picker.svelte` roots derive `hourCycle ?? localeHourCycle(locale)` →
+ pass to the soma Provider (input honours it); publish a bridge context
+ (value get/set + granularity/locale/resolved hourCycle).
+4. `.Time` parts: `date-picker-time.svelte` (one row) + `date-range-picker-time.svelte`
+ (two rows, start/end); register in each `index.ts` + `types.ts`.
+5. langs: `picker-shell.ts` keys `time` / `start` / `end`.
+6. Demos: date-picker uses `` (drop the local bridge/CSS);
+ date-range-picker gains a granularity control + ``.
+
+Verify: `npx svelte-check … --threshold error` (0 new; the only pre-existing
+time-picker error is `time-picker-content.svelte` size/Popover) ·
+`npx vitest run src/uix/soma/components/{date-picker,date-range-picker,time-picker}` ·
+browser DOM checks (preview 5180; `:hover`/screenshot tooling is flaky).
+
+## Done this session (in the commit)
+
+- **Calendar-nav unification** — shared `CalendarNav` context so
+ Calendar/RangeCalendar/DateRangePicker reuse the refined month/year selects +
+ prev/next buttons (deleted the range pickers' bespoke selects). New
+ `calendar-nav-button`, `calendar-select.css`, `visual-context`, soma `calendar-nav`.
+- **Popover** — fixed trigger re-click bounce.
+- **TimePicker → datetime building blocks**: `DayPeriodToggle` now composes the
+ system `` (was native radios; `type=single` `deselectable=false`
+ solid/primary, right-aligned); sliders self-hide per granularity; `ruler` →
+ `showScales`; slider labels follow the canonical label scale per size;
+ active-toggle hover fixed (no wash; `archetypes.css` hover rule now excludes
+ `data-state=on` so a pressed toggle keeps its accent instead of going
+ content-primary/black in light).
+- **DatePicker demo** — locale-derived hour cycle + composed TimePicker time row
+ (this is what the next-task plan generalises to the component).
+- **Perf (audit A31)** — lifted selection/checked sets across 9 providers.
+- **langs** — picker-shell footer strings.
+
+## Do NOT commit (active / separate tracks)
+
+`soma/components/words/**`, `eidos/components/palabras/**` (firm rule), the
+untracked `chronos/` component, `web/routes/demos/**`, and the loose untracked
+docs (`README.md`, `SYSTEM-AUDIT-*`, `Disenando_*`, `COMPARATIVA_SIUM_*`).
+`metrics.ts` langs rode along this commit only because `langs/index.ts` imports it.
diff --git a/src/uix/eidos/archetypes.css b/src/uix/eidos/archetypes.css
index 43d716c28..dff17fa5e 100644
--- a/src/uix/eidos/archetypes.css
+++ b/src/uix/eidos/archetypes.css
@@ -154,18 +154,21 @@
/* Canonical "active option" highlight — hover, keyboard highlight
* (data-highlighted) and active (aria-selected / data-selected), EXCEPT a chosen
- * value (data-state='checked'), which each component paints with its own accent.
+ * value (data-state='checked') or a pressed toggle (data-state='on'), which each
+ * paint their own accent. The neutral menu-row highlight must not clobber a
+ * pressed toggle's solid fill + contrast text (it flipped the active label to
+ * content-primary — black in light themes — on hover).
* One neutral surface so the active row reads identically across select,
* combobox, command, listbox and menus — no per-component repetition.
*/
-[data-archetype='item']:hover:not([data-disabled]):not([data-state='checked']),
-[data-archetype='option']:hover:not([data-disabled]):not([data-state='checked']),
-[data-archetype='item'][data-highlighted]:not([data-state='checked']),
-[data-archetype='option'][data-highlighted]:not([data-state='checked']),
-[data-archetype='item'][aria-selected='true']:not([data-state='checked']),
-[data-archetype='option'][aria-selected='true']:not([data-state='checked']),
-[data-archetype='item'][data-selected]:not([data-state='checked']),
-[data-archetype='option'][data-selected]:not([data-state='checked']) {
+[data-archetype='item']:hover:not([data-disabled]):not([data-state='checked']):not([data-state='on']),
+[data-archetype='option']:hover:not([data-disabled]):not([data-state='checked']):not([data-state='on']),
+[data-archetype='item'][data-highlighted]:not([data-state='checked']):not([data-state='on']),
+[data-archetype='option'][data-highlighted]:not([data-state='checked']):not([data-state='on']),
+[data-archetype='item'][aria-selected='true']:not([data-state='checked']):not([data-state='on']),
+[data-archetype='option'][aria-selected='true']:not([data-state='checked']):not([data-state='on']),
+[data-archetype='item'][data-selected]:not([data-state='checked']):not([data-state='on']),
+[data-archetype='option'][data-selected]:not([data-state='checked']):not([data-state='on']) {
background-color: var(--color-surface-raised);
color: var(--color-content-primary);
}
diff --git a/src/uix/eidos/components/calendar/calendar-month-select.svelte b/src/uix/eidos/components/calendar/calendar-month-select.svelte
index 04350eb44..769187fd4 100644
--- a/src/uix/eidos/components/calendar/calendar-month-select.svelte
+++ b/src/uix/eidos/components/calendar/calendar-month-select.svelte
@@ -8,23 +8,30 @@
*
* No `value` is passed to the grid — a controlled value would make the grid
* fire a select on mount and slam the popover shut as it opens.
+ *
+ * Provider-agnostic: reads the shared `calendarNavContext`, so the SAME part
+ * drives Calendar, DatePicker, RangeCalendar and DateRangePicker.
*/
- import { CalendarProvider } from '$soma/components/calendar';
+ import './calendar-select.css';
+ import { calendarNavContext } from '$soma/components/calendar';
import { ChevronDown } from '$uix/eidos/components/icon';
import { Button } from '$uix/eidos/components/button';
import Popover from '../popover';
import MonthGrid from '../month-grid';
+ import { getCalendarVisual } from './visual-context';
import type { MonthValue, MonthGridProviderSnippetProps } from '$soma/components/month-grid';
- const provider = CalendarProvider.require();
+ const nav = calendarNavContext.get();
+ // Inherit the host calendar/picker size so the grid matches.
+ const visual = getCalendarVisual();
let open = $state(false);
- const placeholderYear = $derived(provider.opts.placeholder.current.year);
- const placeholderMonth = $derived(provider.opts.placeholder.current.month);
+ const placeholderYear = $derived(nav.getPlaceholder().year);
+ const placeholderMonth = $derived(nav.getPlaceholder().month);
// Abbreviated month (matches the grid) so the trigger stays compact and never
// grows the header.
const monthLabel = $derived(
- new Intl.DateTimeFormat(provider.effectiveLocale, { month: 'short' }).format(
+ new Intl.DateTimeFormat(nav.effectiveLocale, { month: 'short' }).format(
new Date(2000, placeholderMonth - 1, 1)
)
);
@@ -34,7 +41,7 @@
function onValueChange(next: MonthValue | undefined): void {
if (!next) return;
- provider.setMonth(next.month);
+ nav.setMonth(next.month);
open = false;
}
@@ -42,7 +49,7 @@
{#snippet child({ props }: { props: Record })}
-
-
+
{#snippet children({ months }: MonthGridProviderSnippetProps)}
{#each months as month (month)}
diff --git a/src/uix/eidos/components/calendar/calendar-nav-button.svelte b/src/uix/eidos/components/calendar/calendar-nav-button.svelte
new file mode 100644
index 000000000..208e910be
--- /dev/null
+++ b/src/uix/eidos/components/calendar/calendar-nav-button.svelte
@@ -0,0 +1,34 @@
+
+
+
+ {#if children}
+ {@render children()}
+ {:else if direction === 'prev'}
+
+ {:else}
+
+ {/if}
+
diff --git a/src/uix/eidos/components/calendar/calendar-next-button.svelte b/src/uix/eidos/components/calendar/calendar-next-button.svelte
index 06c0f9650..830becca5 100644
--- a/src/uix/eidos/components/calendar/calendar-next-button.svelte
+++ b/src/uix/eidos/components/calendar/calendar-next-button.svelte
@@ -7,8 +7,7 @@
*/
import { ActiveEidos } from '$uix/eidos';
import * as Calendar from '$soma/components/calendar';
- import { IconButton } from '$uix/eidos/components/icon-button';
- import { ChevronRight } from '$uix/eidos/components/icon';
+ import CalendarNavButton from './calendar-nav-button.svelte';
import type { CalendarNextButtonProps } from './types';
let {
@@ -23,8 +22,6 @@
{#snippet child({ props }: { props: Record })}
-
- {#if bodyContent}{@render bodyContent()}{:else}{/if}
-
+
{/snippet}
diff --git a/src/uix/eidos/components/calendar/calendar-prev-button.svelte b/src/uix/eidos/components/calendar/calendar-prev-button.svelte
index ebb8861ea..4a46d5743 100644
--- a/src/uix/eidos/components/calendar/calendar-prev-button.svelte
+++ b/src/uix/eidos/components/calendar/calendar-prev-button.svelte
@@ -7,8 +7,7 @@
*/
import { ActiveEidos } from '$uix/eidos';
import * as Calendar from '$soma/components/calendar';
- import { IconButton } from '$uix/eidos/components/icon-button';
- import { ChevronLeft } from '$uix/eidos/components/icon';
+ import CalendarNavButton from './calendar-nav-button.svelte';
import type { CalendarPrevButtonProps } from './types';
let {
@@ -23,8 +22,6 @@
{#snippet child({ props }: { props: Record })}
-
- {#if bodyContent}{@render bodyContent()}{:else}{/if}
-
+
{/snippet}
diff --git a/src/uix/eidos/components/calendar/calendar-select.css b/src/uix/eidos/components/calendar/calendar-select.css
new file mode 100644
index 000000000..1ad3deab9
--- /dev/null
+++ b/src/uix/eidos/components/calendar/calendar-select.css
@@ -0,0 +1,45 @@
+/* Header month / year SELECTORS — shared chrome for every calendar-flavoured
+ component (Calendar, DatePicker, RangeCalendar, DateRangePicker). Imported by
+ the selector components themselves so the styling travels with them instead of
+ living on the [data-calendar] host (which range/date-range never render). */
+
+/* The trigger Button's own font scale is coarser than the calendar's (its xs is
+ 14px while the calendar's is 12px), which left the button box sized for a
+ bigger font than the label showed (the size incoherence). Pin the Button font
+ to the calendar font so the box, label and chevron read as ONE size. */
+[data-calendar-month-select][data-button],
+[data-calendar-year-select][data-button] {
+ font-size: var(--_calendar-font-size, var(--calendar-font-size-md));
+}
+
+[data-calendar-select-label] {
+ /* Bump the trigger label to the host's type size (the Button's own sm font is
+ smaller). Falls back to the md token outside a [data-calendar] host so the
+ shared selector still reads correctly in range / date-range. */
+ font-size: var(--_calendar-font-size, var(--calendar-font-size-md));
+ white-space: nowrap;
+}
+
+[data-calendar-select-chevron] {
+ flex: 0 0 auto;
+ inline-size: var(--_calendar-font-size, var(--calendar-font-size-md));
+ block-size: var(--_calendar-font-size, var(--calendar-font-size-md));
+ opacity: 0.6;
+}
+
+/* The grid sits inside the popover's own surface — flatten its standalone
+ surface (border / bg / shadow / outer padding) so the panel isn't doubled. */
+[data-calendar-select-popover] :is([data-month-grid], [data-year-grid]) {
+ border: none;
+ background: transparent;
+ box-shadow: none;
+ padding: 0;
+}
+
+/* Spanish (and other locales') Intl month abbreviations come back lowercase
+ ("ene", "feb"); capitalize so the month reads the same regardless of locale —
+ matching the English "Jan" / "Feb". */
+[data-calendar-month-select],
+[data-calendar-select-popover] [data-month-grid-cell] {
+ text-transform: capitalize;
+}
diff --git a/src/uix/eidos/components/calendar/calendar-year-select.svelte b/src/uix/eidos/components/calendar/calendar-year-select.svelte
index 5a70f6c55..5765effb3 100644
--- a/src/uix/eidos/components/calendar/calendar-year-select.svelte
+++ b/src/uix/eidos/components/calendar/calendar-year-select.svelte
@@ -9,26 +9,33 @@
* time the popover opens) instead of syncing back into the calendar — that
* decoupling avoids a placeholder feedback loop. No `value` is passed so the
* grid never fires a select on mount and slams the popover shut.
+ *
+ * Provider-agnostic: reads the shared `calendarNavContext`, so the SAME part
+ * drives Calendar, DatePicker, RangeCalendar and DateRangePicker.
*/
- import { CalendarProvider } from '$soma/components/calendar';
+ import './calendar-select.css';
+ import { calendarNavContext } from '$soma/components/calendar';
import { ChevronDown, ChevronLeft, ChevronRight } from '$uix/eidos/components/icon';
import { Button } from '$uix/eidos/components/button';
import Popover from '../popover';
import YearGrid from '../year-grid';
+ import { getCalendarVisual } from './visual-context';
import type { YearValue, YearPlaceholder, YearGridProviderSnippetProps } from '$soma/components/year-grid';
- const provider = CalendarProvider.require();
+ const nav = calendarNavContext.get();
+ // Inherit the host calendar/picker size so the grid matches.
+ const visual = getCalendarVisual();
let open = $state(false);
- const selectedYear = $derived(provider.opts.placeholder.current.year);
+ const selectedYear = $derived(nav.getPlaceholder().year);
// Local decade browsing — re-seeded from the calendar on each open so the
// page starts on the selected year.
- let navYear = $state(provider.opts.placeholder.current.year);
+ let navYear = $state(nav.getPlaceholder().year);
$effect(() => {
- if (open) navYear = provider.opts.placeholder.current.year;
+ if (open) navYear = nav.getPlaceholder().year;
});
- const yearLabel = $derived(provider.formatYear(selectedYear));
+ const yearLabel = $derived(nav.formatYear(selectedYear));
const placeholder = $derived({ year: navYear });
// Highlight the selected year with the SELECTED (filled) style.
const value = $derived({ year: selectedYear });
@@ -38,7 +45,7 @@
}
function onValueChange(next: YearValue | undefined): void {
if (!next) return;
- provider.setYear(next.year);
+ nav.setYear(next.year);
open = false;
}
@@ -46,7 +53,7 @@
{#snippet child({ props }: { props: Record })}
-
+ {yearLabel}
@@ -55,10 +62,11 @@
diff --git a/src/uix/eidos/components/calendar/calendar.css b/src/uix/eidos/components/calendar/calendar.css
index a489609d7..4bcd57403 100644
--- a/src/uix/eidos/components/calendar/calendar.css
+++ b/src/uix/eidos/components/calendar/calendar.css
@@ -162,41 +162,10 @@
outline-offset: 0;
}
-/* The month / year trigger is now the system (composed via the
- Popover.Trigger `child` snippet). Its box / border / bg / hover / focus /
- disabled all come from the primitive — only the label font + chevron remain. */
-
-[data-calendar-select-label] {
- /* The Button's sm size is 14px; bump the label to the picker font (16px) so
- the month/year text matches the calendar. Set on the label (not the Button
- token) so it wins over the inherited Button font without a specificity war. */
- font-size: var(--_calendar-font-size);
- white-space: nowrap;
-}
-
-[data-calendar-select-chevron] {
- flex: 0 0 auto;
- inline-size: var(--_calendar-font-size);
- block-size: var(--_calendar-font-size);
- opacity: 0.6;
-}
-
-/* The grid sits inside the popover's own surface — flatten its standalone
- surface (border / bg / shadow / outer padding) so the panel isn't doubled. */
-[data-calendar-select-popover] :is([data-month-grid], [data-year-grid]) {
- border: none;
- background: transparent;
- box-shadow: none;
- padding: 0;
-}
-
-/* Spanish (and other locales') Intl month abbreviations come back lowercase
- ("ene", "feb"); capitalize so the month reads the same regardless of locale —
- matching the English "Jan" / "Feb". */
-[data-calendar-month-select],
-[data-calendar-select-popover] [data-month-grid-cell] {
- text-transform: capitalize;
-}
+/* Month / year selector chrome (label font, chevron, popover flatten, month
+ capitalization) moved to `calendar-select.css`, imported by the selector
+ components themselves — so RangeCalendar / DateRangePicker, which never render
+ a [data-calendar] host, still get the styling when they reuse the selectors. */
[data-calendar-grid] {
inline-size: max-content;
diff --git a/src/uix/eidos/components/calendar/calendar.svelte b/src/uix/eidos/components/calendar/calendar.svelte
index 25ed92ddf..b9e355b13 100644
--- a/src/uix/eidos/components/calendar/calendar.svelte
+++ b/src/uix/eidos/components/calendar/calendar.svelte
@@ -2,6 +2,7 @@
import './calendar.css';
import { ActiveEidos } from '$uix/eidos';
import * as Calendar from '$soma/components/calendar';
+ import { setCalendarVisual } from './visual-context';
import type { CalendarProps, CalendarProviderSnippetProps } from './types';
let {
@@ -16,6 +17,8 @@
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
+ // Month/year select popovers (MonthGrid/YearGrid) inherit this size.
+ setCalendarVisual({ getSize: () => resolvedSize });
(CALENDAR_VISUAL);
+}
diff --git a/src/uix/eidos/components/code-block/code-block.css b/src/uix/eidos/components/code-block/code-block.css
index 5f56eac63..1dbb6c104 100644
--- a/src/uix/eidos/components/code-block/code-block.css
+++ b/src/uix/eidos/components/code-block/code-block.css
@@ -73,38 +73,16 @@
margin-inline-end: auto;
}
+/* The copy control composes (Trigger + Indicator). The Trigger's
+ chrome comes from button.css; this only lays out the trigger + the transient
+ "Copied" indicator and sizes the indicator text. */
[data-code-block-copy] {
- appearance: none;
- font: inherit;
- font-family: var(--font-family-mono);
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+ flex: 0 0 auto;
font-size: var(--font-size-xs);
- color: var(--color-content-secondary);
- background: transparent;
- border: var(--border-width) solid var(--color-neutral-border);
- border-radius: var(--radius-sm);
- padding-inline: var(--space-2);
- padding-block: 2px;
- cursor: pointer;
- white-space: nowrap;
- transition:
- background var(--duration-fast) ease,
- border-color var(--duration-fast) ease,
- color var(--duration-fast) ease;
-}
-
-[data-code-block-copy]:hover {
- background: var(--color-neutral-hover);
- color: var(--color-content-primary);
-}
-
-[data-code-block-copy]:focus-visible {
- outline: var(--focus-ring-width) solid var(--focus-ring-color);
- outline-offset: 0;
-}
-
-[data-code-block-copy][data-copied] {
color: var(--color-affirm-text);
- border-color: var(--color-affirm-border);
}
[data-code-block-pre] {
diff --git a/src/uix/eidos/components/code-block/code-block.svelte b/src/uix/eidos/components/code-block/code-block.svelte
index e9330b57e..97343a3e0 100644
--- a/src/uix/eidos/components/code-block/code-block.svelte
+++ b/src/uix/eidos/components/code-block/code-block.svelte
@@ -7,13 +7,14 @@
* {`const x = 1;\nconsole.log(x);`}
*
*
- * Renders a `
` shell with an optional header
- * (title + language badge + copy button) and a `
` inner
- * block. The copy button is a leaf utility — no Sema events.
+ * Renders a `
` shell with an optional header (title +
+ * language badge + copy control) and a `
` inner block. The copy
+ * control composes the canonical `` (Trigger + Indicator) — no
+ * bespoke copy button or clipboard logic, and the accessible "copy" label
+ * comes from the clipboard component's own langs.
*/
- import { onDestroy } from 'svelte';
import { ActiveEidos } from '$uix/eidos';
- import type { TimerHandle } from '$libs/timer';
+ import { Clipboard } from '$uix/eidos/components/clipboard';
import type { CodeBlockProps } from './types';
let {
@@ -31,8 +32,9 @@
const eidos = ActiveEidos.require();
let codeRef = $state(null);
- let copied = $state(false);
- let copyTimer: TimerHandle | undefined;
+ // The copied text is the rendered code — reactive once the ref binds
+ // on mount. (Static code blocks: settles once; that's the common case.)
+ const codeText = $derived(codeRef?.textContent ?? '');
const resolvedVariant = $derived(eidos.resolve(variant) ?? 'surface');
const resolvedSize = $derived(eidos.resolve(size));
@@ -53,24 +55,6 @@
});
const hasHeader = $derived(Boolean(title) || Boolean(language) || showCopy);
-
- async function handleCopy() {
- if (typeof navigator === 'undefined' || !codeRef) return;
- const text = codeRef.textContent ?? '';
- try {
- await navigator.clipboard.writeText(text);
- copied = true;
- copyTimer?.cancel();
- copyTimer = eidos.timers.schedule(null, 1800, () => {
- copied = false;
- });
- } catch {
- // Clipboard unavailable — keep silent. The button still fires
- // onclick so the consumer can wire a fallback if needed.
- }
- }
-
- onDestroy(() => copyTimer?.cancel());