From 471fb77e6127f8d5bf8628590c2a626b17da9a18 Mon Sep 17 00:00:00 2001 From: Alex Grozav Date: Wed, 24 Jun 2026 11:12:41 +0300 Subject: [PATCH 1/3] feat(theme): add input-date recipe Add a segmented date-input recipe family inspired by Nuxt UI's input-date: a wrapper recipe (useInputDateRecipe) that owns the field surface and :focus-within ring as a member of the field family, plus a segment recipe (useInputDateSegmentRecipe) for the individually-focusable date parts. Includes tests, Storybook showcase (registration, component, grids, stories), and docs. Refs UXF-2. --- .../05.components/05.forms/06.input-date.md | 514 ++++++++++++++++++ .../05.forms/{06.otp.md => 07.otp.md} | 0 .../05.forms/{07.radio.md => 08.radio.md} | 0 .../{08.radio-group.md => 09.radio-group.md} | 0 .../05.forms/{09.select.md => 10.select.md} | 0 .../05.forms/{10.slider.md => 11.slider.md} | 0 .../05.forms/{11.switch.md => 12.switch.md} | 0 .../{12.textarea.md => 13.textarea.md} | 0 .../05.forms/{13.toggle.md => 14.toggle.md} | 0 ...{14.toggle-group.md => 15.toggle-group.md} | 0 .../components/input-date/InputDate.vue | 62 +++ .../input-date/preview/InputDateGrid.vue | 22 + .../input-date/preview/InputDateSizeGrid.vue | 21 + .../stories/components/input-date.stories.ts | 154 ++++++ .../components/input-date.styleframe.ts | 43 ++ theme/src/recipes/index.ts | 1 + theme/src/recipes/input-date/index.ts | 2 + .../input-date/useInputDateRecipe.test.ts | 304 +++++++++++ .../recipes/input-date/useInputDateRecipe.ts | 50 ++ .../useInputDateSegmentRecipe.test.ts | 134 +++++ .../input-date/useInputDateSegmentRecipe.ts | 65 +++ 21 files changed, 1372 insertions(+) create mode 100644 apps/docs/content/docs/05.components/05.forms/06.input-date.md rename apps/docs/content/docs/05.components/05.forms/{06.otp.md => 07.otp.md} (100%) rename apps/docs/content/docs/05.components/05.forms/{07.radio.md => 08.radio.md} (100%) rename apps/docs/content/docs/05.components/05.forms/{08.radio-group.md => 09.radio-group.md} (100%) rename apps/docs/content/docs/05.components/05.forms/{09.select.md => 10.select.md} (100%) rename apps/docs/content/docs/05.components/05.forms/{10.slider.md => 11.slider.md} (100%) rename apps/docs/content/docs/05.components/05.forms/{11.switch.md => 12.switch.md} (100%) rename apps/docs/content/docs/05.components/05.forms/{12.textarea.md => 13.textarea.md} (100%) rename apps/docs/content/docs/05.components/05.forms/{13.toggle.md => 14.toggle.md} (100%) rename apps/docs/content/docs/05.components/05.forms/{14.toggle-group.md => 15.toggle-group.md} (100%) create mode 100644 apps/storybook/src/components/components/input-date/InputDate.vue create mode 100644 apps/storybook/src/components/components/input-date/preview/InputDateGrid.vue create mode 100644 apps/storybook/src/components/components/input-date/preview/InputDateSizeGrid.vue create mode 100644 apps/storybook/stories/components/input-date.stories.ts create mode 100644 apps/storybook/stories/components/input-date.styleframe.ts create mode 100644 theme/src/recipes/input-date/index.ts create mode 100644 theme/src/recipes/input-date/useInputDateRecipe.test.ts create mode 100644 theme/src/recipes/input-date/useInputDateRecipe.ts create mode 100644 theme/src/recipes/input-date/useInputDateSegmentRecipe.test.ts create mode 100644 theme/src/recipes/input-date/useInputDateSegmentRecipe.ts diff --git a/apps/docs/content/docs/05.components/05.forms/06.input-date.md b/apps/docs/content/docs/05.components/05.forms/06.input-date.md new file mode 100644 index 00000000..b435683a --- /dev/null +++ b/apps/docs/content/docs/05.components/05.forms/06.input-date.md @@ -0,0 +1,514 @@ +--- +title: Input Date +description: A segmented date input — a wrapper-owned field surface holding individually focusable date segments separated by muted glyphs, with light, dark, and neutral colors, default, soft, and ghost styles, three sizes, and invalid, disabled, and read-only states through the recipe system. +navigation: + icon: false +--- + +## Overview + +The **Input Date** is a segmented date field: a single visual surface that holds individually focusable parts — day, month, year (and optionally hour, minute) — separated by a muted glyph such as `/` or `–`. It is composed of two recipe parts: + +- **`useInputDateRecipe()`** — the wrapper that owns the visual field: border, background, padding, and the `:focus-within` ring. It is a member of the field recipes family, so it shares the same color, style, and state surface as the [Input](/docs/theme/components/input) recipe. +- **`useInputDateSegmentRecipe()`** — the single editable segment that sits inside the field. It is transparent, inherits typography and color from the wrapper, centers its digits with tabular figures, and paints a highlight only on `:focus`. + +The wrapper owns the entire surface and reveals a primary-colored ring on `:focus-within`, so focusing any segment lights the whole field — just like a native text input. Each segment is the focusable element itself, so keyboard focus moves part to part while the surface stays put. A plain `.input-date-separator` glyph sits between segments, muted via `@color.text-weak`. + +The Input Date recipes integrate directly with the default [design tokens preset](/docs/theme/design-tokens/preset) and generate type-safe utility classes at build time with zero runtime CSS. + +::note +**Styling, not behavior.** These recipes style the field and its segments. Parsing input, advancing focus between segments, clamping values, and opening a calendar popover are interaction concerns you wire up yourself (or with a headless library). Calendar / popover styling lives in the [Calendar](/docs/theme/components/calendar) recipe — this recipe styles the segmented input only. +:: + +## Why use the Input Date recipe? + +The Input Date recipe helps you: + +- **Ship faster with sensible defaults**: Get 3 surface colors, 3 styles, and 3 sizes out of the box with a single set of composable calls. +- **Match the rest of your forms**: The wrapper reuses the Input recipe's color / style / state surface, so a date field shares the same visual language as your text inputs. +- **Maintain consistency**: The focus ring, invalid border, and dark-mode surfaces follow the same design rules everywhere. +- **Customize without forking**: Override base styles, default variants, or filter out options you don't need — all through the options API. +- **Stay type-safe**: Full TypeScript support means your editor catches invalid color, style, or size values at compile time. +- **Integrate with your tokens**: Every value references the design tokens preset, so theme changes propagate automatically. + +## Usage + +::steps{level="4"} + +#### Register the recipes + +Add the Input Date recipes to a local Styleframe instance. The global `styleframe.config.ts` provides design tokens and utilities, while the component-level file registers the recipes themselves: + +:::code-tree{default-value="src/components/input-date.styleframe.ts"} + +```ts [src/components/input-date.styleframe.ts] +import { styleframe } from 'virtual:styleframe'; +import { useInputDateRecipe, useInputDateSegmentRecipe } from '@styleframe/theme'; + +const s = styleframe(); + +const inputDate = useInputDateRecipe(s); +const inputDateSegment = useInputDateSegmentRecipe(s); + +export default s; +``` + +```ts [styleframe.config.ts] +import { styleframe } from 'styleframe'; +import { useDesignTokensPreset, useUtilitiesPreset } from '@styleframe/theme'; + +const s = styleframe(); + +useDesignTokensPreset(s); +useUtilitiesPreset(s); + +export default s; +``` + +::: + +#### Build the component + +Put the `inputDate` class on the wrapper, nest a `.input-date-field` row inside it, and render one `inputDateSegment` span per part, interleaved with a `.input-date-separator` glyph. The `invalid`, `disabled`, and `readonly` axes accept booleans directly: + +::framework-switcher + +#vue + +```vue [src/components/InputDate.vue] + + + +``` + +#react + +```tsx [src/components/InputDate.tsx] +import { inputDate, inputDateSegment } from "virtual:styleframe"; + +interface InputDateProps { + color?: "light" | "dark" | "neutral"; + variant?: "default" | "soft" | "ghost"; + size?: "sm" | "md" | "lg"; + invalid?: boolean; + disabled?: boolean; + readonly?: boolean; + value?: string; + separator?: string; +} + +export function InputDate({ + color = "neutral", + variant = "default", + size = "md", + invalid = false, + disabled = false, + readonly = false, + value = "12/31/2026", + separator = "/", +}: InputDateProps) { + const segments = value.split(separator); + const focusable = !disabled && !readonly; + + return ( +
+ + {segments.map((segment, index) => ( + + + {segment} + + {index < segments.length - 1 && ( + + )} + + ))} + +
+ ); +} +``` + +#other + +The `inputDate()` and `inputDateSegment()` runtimes each return a class string. Apply them however your framework binds classes: + +```ts [src/components/input-date.ts] +import { inputDate, inputDateSegment } from "virtual:styleframe"; + +const wrapperClasses = inputDate({ color: "neutral", variant: "default", size: "md" }); +// → "input-date _display:inline-flex _align-items:center ..." + +const segmentClasses = inputDateSegment({ size: "md" }); +// → "input-date-segment _display:inline-flex _font-variant-numeric:tabular-nums ..." +``` + +```html [src/components/input-date.html] +
+ + 12 + + 31 + + 2026 + +
+``` + +:: + +#### See it in action + +:::story-preview +--- +story: theme-recipes-forms-inputdate--default +panel: true +--- +::: + +:: + +## Colors + +The Input Date wrapper includes 3 color variants: `light`, `dark`, and `neutral`. Like the [Input](/docs/theme/components/input) and [OTP](/docs/theme/components/otp) recipes, these are neutral-spectrum surface colors rather than status colors — the color sets the field background and border, while the `:focus-within` ring stays `@color.primary`. + +The `neutral` color adapts automatically: a light surface in light mode and a dark surface in dark mode, making it the safest default for general-purpose forms. + +::story-preview +--- +story: theme-recipes-forms-inputdate--neutral +panel: true +--- +:: + +### Color Reference + +::story-preview +--- +story: theme-recipes-forms-inputdate--all-variants +height: 420 +--- +:: + +| Color | Token | Use Case | +|-------|-------|----------| +| `light` | `@color.white` / `@color.gray-200` | Light surfaces, stays light in dark mode | +| `dark` | `@color.gray-900` / `@color.gray-700` | Dark surfaces, stays dark in light mode | +| `neutral` | Adaptive (light ↔ dark) | Default color, adapts to the current color scheme | + +::tip +**Pro tip:** Use `neutral` as your default date-field color. It adapts to the user's color scheme automatically, so you don't need to manage light and dark surfaces separately. +:: + +## Variants + +Three visual styles control how much surface the field shows. All three share the same `:focus-within` ring and invalid border — they differ only in their resting background and border. + +### Default + +`default` draws a bordered field on a solid surface — the most legible option and the right default for forms. + +::story-preview +--- +story: theme-recipes-forms-inputdate--default-variant +panel: true +--- +:: + +### Soft + +`soft` fills the field with a subtle tinted background and a matching border, for a gentler look that still reads as an input. + +::story-preview +--- +story: theme-recipes-forms-inputdate--soft +panel: true +--- +:: + +### Ghost + +`ghost` is transparent until focused, for dense or low-chrome layouts. Pair it with a clear label so the field stays discoverable. + +::story-preview +--- +story: theme-recipes-forms-inputdate--ghost +panel: true +--- +:: + +## Sizes + +Three size variants from `sm` to `lg` control the font size, field padding, and border radius. Pass the same `size` to the wrapper and to each segment so the segment typography and corner radius track the field. + +::story-preview +--- +story: theme-recipes-forms-inputdate--all-sizes +height: 360 +--- +:: + +### Size Reference + +| Size | Font Size | Padding (Y / X) | Border Radius | +|------|-----------|-----------------|---------------| +| `sm` | `@font-size.xs` | `@0.375` / `@0.625` | `@border-radius.sm` | +| `md` | `@font-size.sm` | `@0.5` / `@0.75` | `@border-radius.md` | +| `lg` | `@font-size.md` | `@0.625` / `@0.875` | `@border-radius.md` | + +::note +**Good to know:** The segment recipe carries its own `size` axis that scales the segment font, horizontal padding, and the highlight's corner radius. Spread the same `size` to both parts so they stay in proportion. +:: + +## States + +### Invalid + +Set the `invalid` state when the entered date is wrong or out of range. The field border and the `:focus-within` ring switch to `@color.error`, layered over whatever color and style the field already uses. + +::story-preview +--- +story: theme-recipes-forms-inputdate--invalid +panel: true +--- +:: + +### Disabled + +The `disabled` state dims the field to `0.5` opacity, switches the cursor to `not-allowed`, and blocks pointer interaction. Drop `tabindex` from the segments so the field also leaves the tab order. + +::story-preview +--- +story: theme-recipes-forms-inputdate--disabled +panel: true +--- +:: + +### Read-only + +Applies a subtle background shift and a default cursor while keeping the value selectable. Keep the segments out of the tab order (or non-editable) so the value is shown but not changed. + +::story-preview +--- +story: theme-recipes-forms-inputdate--read-only +panel: true +--- +:: + +::note +**Good to know:** `invalid` is applied before `readonly` and `disabled` in the compound variant order, so an invalid field keeps its error border even when it is also read-only, and a disabled field still dims on top of any other state. +:: + +## Anatomy + +The Input Date is composed of two recipes plus a plain separator selector. + +| Part | Recipe / Selector | Role | +|------|-------------------|------| +| **Field** | `useInputDateRecipe()` | The `.input-date` wrapper — owns the visual field (border, background, padding, `:focus-within` ring) and lays out the inner `.input-date-field` row | +| **Segment** | `useInputDateSegmentRecipe()` | The `.input-date-segment` editable part — transparent, inherits typography, centers digits with tabular figures, and highlights on `:focus` | +| **Separator** | `.input-date-separator` | A muted, non-selectable glyph (`@color.text-weak`) between segments — registered by the wrapper's setup, no axis of its own | + +```html +
+ + 12 + + 31 + + +
+``` + +The wrapper carries the entire color / style / state surface; the segments are transparent and only paint a highlight on focus, so every segment in a field stays visually consistent. + +## Accessibility + +- **Group and label the field.** Wrap the segments in an element with `role="group"` and an `aria-label` (e.g. "Date input") so assistive technology announces them as one control rather than several unrelated fields. +- **Expose each segment as a spinbutton.** Give every editable segment `role="spinbutton"` with `aria-valuenow` / `aria-valuetext` so screen readers announce the current value and arrow-key editing is understood. +- **Keep segments focusable.** Make each segment reachable with `tabindex="0"` (when not disabled or read-only) so keyboard users can move part to part; the wrapper's `:focus-within` ring lights the whole field as they go. +- **Don't rely on color alone.** The `invalid` state changes the border and ring color; pair it with a visible error message so the failure isn't conveyed by color only, satisfying [WCAG 1.4.1](https://www.w3.org/WAI/WCAG21/Understanding/use-of-color.html). +- **Reflect disabled and read-only.** Drop `tabindex` (and set `aria-disabled`) for `disabled`, and keep segments out of the edit path for `readonly`, so the field behaves correctly for keyboard and assistive-technology users. +- **Verify contrast.** The `:focus-within` ring and the segment focus highlight use `@color.primary`. Default tokens meet WCAG AA; if you override the primary color, verify with the [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/). + +## Customization + +### Overriding Defaults + +Each Input Date composable accepts an optional second argument to override any part of the recipe configuration. Overrides are deep-merged with the defaults, so you only need to specify the properties you want to change: + +```ts [src/components/input-date.styleframe.ts] +import { styleframe } from 'virtual:styleframe'; +import { useInputDateRecipe } from '@styleframe/theme'; + +const s = styleframe(); + +const inputDate = useInputDateRecipe(s, { + defaultVariants: { + color: 'neutral', + size: 'lg', + }, +}); + +export default s; +``` + +### Filtering Variants + +If you only need a subset of the available variants, use the `filter` option to limit which values are generated. This reduces the output CSS and keeps your component API focused: + +```ts [src/components/input-date.styleframe.ts] +import { styleframe } from 'virtual:styleframe'; +import { useInputDateRecipe } from '@styleframe/theme'; + +const s = styleframe(); + +// Only generate the neutral color and the default style +const inputDate = useInputDateRecipe(s, { + filter: { + color: ['neutral'], + variant: ['default'], + }, +}); + +export default s; +``` + +::note +**Good to know:** Filtering also removes compound variants and adjusts default variants that reference filtered-out values, so your recipe stays consistent. +:: + +## API Reference + +### `useInputDateRecipe(s, options?)` + +Creates the Input Date wrapper recipe — the `.input-date` field that owns the visual surface (border, background, padding, `:focus-within` ring) and lays out the inner segment row. Its setup callback registers the `.input-date-field` flex row and the muted `.input-date-separator` glyph. + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `s` | `Styleframe` | The Styleframe instance | +| `options` | `DeepPartial` | Optional overrides for the recipe configuration | +| `options.base` | `VariantDeclarationsBlock` | Custom base styles for the wrapper | +| `options.variants` | `Variants` | Custom variant definitions for the recipe | +| `options.defaultVariants` | `Record` | Default variant values for the recipe | +| `options.filter` | `Record` | Limit which variant values are generated | + +**Variants:** + +| Variant | Options | Default | +|---------|---------|---------| +| `color` | `light`, `dark`, `neutral` | `neutral` | +| `variant` | `default`, `soft`, `ghost` | `default` | +| `size` | `sm`, `md`, `lg` | `md` | +| `invalid` | `true`, `false` | `false` | +| `disabled` | `true`, `false` | `false` | +| `readonly` | `true`, `false` | `false` | + +### `useInputDateSegmentRecipe(s, options?)` + +Creates the Input Date segment recipe — the `.input-date-segment` editable part that is transparent, inherits typography from the wrapper, centers its digits with tabular figures, and highlights on `:focus` (`@color.primary` background, `@color.white` text). It carries no color or style axis; all surface styling lives on the wrapper. Accepts the same parameters as `useInputDateRecipe`. + +**Variants:** + +| Variant | Options | Default | +|---------|---------|---------| +| `size` | `sm`, `md`, `lg` | `md` | + +[Learn more about recipes →](/docs/api/recipes) + +## Best Practices + +- **Pass `size` to both parts**: Spread the same `size` to the wrapper and the segments so the field and segment typography stay in proportion. +- **Use `neutral` for general forms**: The neutral color adapts to light and dark mode automatically, making it the safest default. +- **Expose segments as spinbuttons**: Give each segment `role="spinbutton"` and keep it focusable so arrow-key editing and screen-reader announcement work. +- **Group and label the field**: Use `role="group"` and an `aria-label` so the segments are announced as a single control. +- **Filter what you don't need**: If your forms use only the neutral color and default style, pass a `filter` option to reduce generated CSS. + +## FAQ + +::accordion + +:::accordion-item{label="Does this recipe handle date parsing and segment focus?" icon="i-lucide-circle-help"} +No — the recipe only styles the field and its segments. Parsing the typed value, advancing focus between segments, clamping ranges, and masking are interaction concerns you implement yourself or delegate to a headless component. Keeping behavior out of the recipe keeps it framework-agnostic and zero-runtime. +::: + +:::accordion-item{label="Does this style the calendar / date picker popover?" icon="i-lucide-circle-help"} +No. This recipe styles the segmented date *input* surface only. The calendar grid and popover styling live in the [Calendar](/docs/theme/components/calendar) recipe — compose the two when you need a full date picker. +::: + +:::accordion-item{label="Why is the color light/dark/neutral instead of primary/success/error?" icon="i-lucide-circle-help"} +The `color` axis controls the field surface, which reflects the form's surface rather than a status. This mirrors the [Input](/docs/theme/components/input) recipe's color model. For an error state, use the `invalid` state, which switches the border and focus ring to `@color.error`. +::: + +:::accordion-item{label="How is the Input Date related to the Input recipe?" icon="i-lucide-circle-help"} +The wrapper shares the Input recipe's color / style / state surface through an internal helper, so the two stay visually consistent — same `:focus-within` ring, invalid border, and dark-mode surfaces. The difference is structural: an Input wraps one transparent native ``, while an Input Date lays out several focusable segments separated by muted glyphs inside the same surface. +::: + +:::accordion-item{label="How do I render different date formats?" icon="i-lucide-circle-help"} +Split your value on the separator and render one `inputDateSegment` per part, interleaving a `.input-date-separator` between them. Change the `separator` (e.g. `/`, `-`, or `:` for time) and the number of segments to match the format you need — the wrapper's `.input-date-field` row lays them out automatically. +::: + +:: diff --git a/apps/docs/content/docs/05.components/05.forms/06.otp.md b/apps/docs/content/docs/05.components/05.forms/07.otp.md similarity index 100% rename from apps/docs/content/docs/05.components/05.forms/06.otp.md rename to apps/docs/content/docs/05.components/05.forms/07.otp.md diff --git a/apps/docs/content/docs/05.components/05.forms/07.radio.md b/apps/docs/content/docs/05.components/05.forms/08.radio.md similarity index 100% rename from apps/docs/content/docs/05.components/05.forms/07.radio.md rename to apps/docs/content/docs/05.components/05.forms/08.radio.md diff --git a/apps/docs/content/docs/05.components/05.forms/08.radio-group.md b/apps/docs/content/docs/05.components/05.forms/09.radio-group.md similarity index 100% rename from apps/docs/content/docs/05.components/05.forms/08.radio-group.md rename to apps/docs/content/docs/05.components/05.forms/09.radio-group.md diff --git a/apps/docs/content/docs/05.components/05.forms/09.select.md b/apps/docs/content/docs/05.components/05.forms/10.select.md similarity index 100% rename from apps/docs/content/docs/05.components/05.forms/09.select.md rename to apps/docs/content/docs/05.components/05.forms/10.select.md diff --git a/apps/docs/content/docs/05.components/05.forms/10.slider.md b/apps/docs/content/docs/05.components/05.forms/11.slider.md similarity index 100% rename from apps/docs/content/docs/05.components/05.forms/10.slider.md rename to apps/docs/content/docs/05.components/05.forms/11.slider.md diff --git a/apps/docs/content/docs/05.components/05.forms/11.switch.md b/apps/docs/content/docs/05.components/05.forms/12.switch.md similarity index 100% rename from apps/docs/content/docs/05.components/05.forms/11.switch.md rename to apps/docs/content/docs/05.components/05.forms/12.switch.md diff --git a/apps/docs/content/docs/05.components/05.forms/12.textarea.md b/apps/docs/content/docs/05.components/05.forms/13.textarea.md similarity index 100% rename from apps/docs/content/docs/05.components/05.forms/12.textarea.md rename to apps/docs/content/docs/05.components/05.forms/13.textarea.md diff --git a/apps/docs/content/docs/05.components/05.forms/13.toggle.md b/apps/docs/content/docs/05.components/05.forms/14.toggle.md similarity index 100% rename from apps/docs/content/docs/05.components/05.forms/13.toggle.md rename to apps/docs/content/docs/05.components/05.forms/14.toggle.md diff --git a/apps/docs/content/docs/05.components/05.forms/14.toggle-group.md b/apps/docs/content/docs/05.components/05.forms/15.toggle-group.md similarity index 100% rename from apps/docs/content/docs/05.components/05.forms/14.toggle-group.md rename to apps/docs/content/docs/05.components/05.forms/15.toggle-group.md diff --git a/apps/storybook/src/components/components/input-date/InputDate.vue b/apps/storybook/src/components/components/input-date/InputDate.vue new file mode 100644 index 00000000..cbd08997 --- /dev/null +++ b/apps/storybook/src/components/components/input-date/InputDate.vue @@ -0,0 +1,62 @@ + + + diff --git a/apps/storybook/src/components/components/input-date/preview/InputDateGrid.vue b/apps/storybook/src/components/components/input-date/preview/InputDateGrid.vue new file mode 100644 index 00000000..f1ddb31f --- /dev/null +++ b/apps/storybook/src/components/components/input-date/preview/InputDateGrid.vue @@ -0,0 +1,22 @@ + + + diff --git a/apps/storybook/src/components/components/input-date/preview/InputDateSizeGrid.vue b/apps/storybook/src/components/components/input-date/preview/InputDateSizeGrid.vue new file mode 100644 index 00000000..fb216ca6 --- /dev/null +++ b/apps/storybook/src/components/components/input-date/preview/InputDateSizeGrid.vue @@ -0,0 +1,21 @@ + + + diff --git a/apps/storybook/stories/components/input-date.stories.ts b/apps/storybook/stories/components/input-date.stories.ts new file mode 100644 index 00000000..e4f0a740 --- /dev/null +++ b/apps/storybook/stories/components/input-date.stories.ts @@ -0,0 +1,154 @@ +import type { Meta, StoryObj } from "@storybook/vue3-vite"; + +import InputDate from "@/components/components/input-date/InputDate.vue"; +import InputDateGrid from "@/components/components/input-date/preview/InputDateGrid.vue"; +import InputDateSizeGrid from "@/components/components/input-date/preview/InputDateSizeGrid.vue"; + +const colors = ["neutral", "light", "dark"] as const; +const variants = ["default", "soft", "ghost"] as const; +const sizes = ["sm", "md", "lg"] as const; + +const meta = { + title: "Theme/Recipes/Forms/InputDate", + component: InputDate, + tags: ["autodocs"], + parameters: { + layout: "padded", + }, + argTypes: { + color: { + control: "select", + options: colors, + description: "The color variant of the date input", + }, + variant: { + control: "select", + options: variants, + description: "The visual style variant", + }, + size: { + control: "select", + options: sizes, + description: "The size of the date input", + }, + invalid: { + control: "boolean", + description: "Whether the date input is in an invalid state", + }, + disabled: { + control: "boolean", + description: "Whether the date input is disabled", + }, + readonly: { + control: "boolean", + description: "Whether the date input is read-only", + }, + value: { + control: "text", + description: "The date value rendered across the segments", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + color: "neutral", + variant: "default", + size: "md", + invalid: false, + value: "12/31/2026", + }, +}; + +export const AllVariants: StoryObj = { + render: () => ({ + components: { InputDateGrid }, + template: "", + }), +}; + +export const AllSizes: StoryObj = { + render: () => ({ + components: { InputDateSizeGrid }, + template: "", + }), +}; + +// Individual color stories +export const Neutral: Story = { + args: { + color: "neutral", + }, +}; + +export const Light: Story = { + args: { + color: "light", + }, +}; + +export const Dark: Story = { + args: { + color: "dark", + }, +}; + +// Variant stories +export const DefaultVariant: Story = { + args: { + variant: "default", + }, +}; + +export const Soft: Story = { + args: { + variant: "soft", + }, +}; + +export const Ghost: Story = { + args: { + variant: "ghost", + }, +}; + +// Size stories +export const Small: Story = { + args: { + size: "sm", + }, +}; + +export const Medium: Story = { + args: { + size: "md", + }, +}; + +export const Large: Story = { + args: { + size: "lg", + }, +}; + +// State stories +export const Invalid: Story = { + args: { + invalid: true, + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + }, +}; + +export const ReadOnly: Story = { + args: { + readonly: true, + }, +}; diff --git a/apps/storybook/stories/components/input-date.styleframe.ts b/apps/storybook/stories/components/input-date.styleframe.ts new file mode 100644 index 00000000..2fbf27c2 --- /dev/null +++ b/apps/storybook/stories/components/input-date.styleframe.ts @@ -0,0 +1,43 @@ +import { + useInputDateRecipe, + useInputDateSegmentRecipe, +} from "@styleframe/theme"; +import { styleframe } from "virtual:styleframe"; + +const s = styleframe(); +const { selector } = s; + +// Initialize input-date recipes +export const inputDate = useInputDateRecipe(s); +export const inputDateSegment = useInputDateSegmentRecipe(s); + +// Container styles for story layout +selector(".input-date-grid", { + display: "flex", + flexWrap: "wrap", + gap: "@spacing.md", + padding: "@spacing.md", + alignItems: "flex-start", +}); + +selector(".input-date-section", { + display: "flex", + flexDirection: "column", + gap: "@spacing.lg", + padding: "@spacing.md", +}); + +selector(".input-date-row", { + display: "flex", + flexWrap: "wrap", + gap: "@spacing.sm", + alignItems: "center", +}); + +selector(".input-date-label", { + fontSize: "@font-size.sm", + fontWeight: "@font-weight.semibold", + minWidth: "80px", +}); + +export default s; diff --git a/theme/src/recipes/index.ts b/theme/src/recipes/index.ts index a66c1cd3..5007407a 100644 --- a/theme/src/recipes/index.ts +++ b/theme/src/recipes/index.ts @@ -16,6 +16,7 @@ export * from "./dropdown"; export * from "./field-group"; export * from "./hamburger-menu"; export * from "./input"; +export * from "./input-date"; export * from "./media"; export * from "./modal"; export * from "./nav"; diff --git a/theme/src/recipes/input-date/index.ts b/theme/src/recipes/input-date/index.ts new file mode 100644 index 00000000..8807df7f --- /dev/null +++ b/theme/src/recipes/input-date/index.ts @@ -0,0 +1,2 @@ +export * from "./useInputDateRecipe"; +export * from "./useInputDateSegmentRecipe"; diff --git a/theme/src/recipes/input-date/useInputDateRecipe.test.ts b/theme/src/recipes/input-date/useInputDateRecipe.test.ts new file mode 100644 index 00000000..424b2d43 --- /dev/null +++ b/theme/src/recipes/input-date/useInputDateRecipe.test.ts @@ -0,0 +1,304 @@ +import { styleframe } from "@styleframe/core"; +import { useDarkModifier } from "../../modifiers/useMediaPreferenceModifiers"; +import { + useFocusWithinModifier, + useHoverModifier, +} from "../../modifiers/usePseudoStateModifiers"; +import { useInputDateRecipe } from "./useInputDateRecipe"; + +function createInstance() { + const s = styleframe(); + for (const name of [ + "display", + "alignItems", + "gap", + "flexGrow", + "minWidth", + "width", + "fontFamily", + "fontSize", + "fontWeight", + "lineHeight", + "border", + "borderWidth", + "borderStyle", + "borderColor", + "borderRadius", + "padding", + "paddingTop", + "paddingBottom", + "paddingLeft", + "paddingRight", + "background", + "color", + "userSelect", + "outline", + "outlineWidth", + "outlineStyle", + "outlineColor", + "outlineOffset", + "cursor", + "opacity", + "pointerEvents", + "transitionProperty", + "transitionTimingFunction", + "transitionDuration", + ]) { + s.utility(name, ({ value }) => ({ [name]: value })); + } + s.variable("color.text-weakest", "#64748B", { default: true }); + s.variable("color.text-weak", "#475569", { default: true }); + s.variable("color.gray-400", "#94A3B8", { default: true }); + s.variable("0.125", "0.125rem", { default: true }); + useDarkModifier(s); + useHoverModifier(s); + useFocusWithinModifier(s); + return s; +} + +describe("useInputDateRecipe", () => { + it("should create a recipe with correct metadata", () => { + const s = createInstance(); + const recipe = useInputDateRecipe(s); + + expect(recipe.type).toBe("recipe"); + expect(recipe.name).toBe("input-date"); + }); + + it("should have correct base styles with a focus-within ring", () => { + const s = createInstance(); + const recipe = useInputDateRecipe(s); + + expect(recipe.base).toEqual({ + display: "inline-flex", + alignItems: "center", + fontFamily: "inherit", + fontSize: "@font-size.sm", + fontWeight: "@font-weight.normal", + lineHeight: "@line-height.normal", + borderWidth: "@border-width.thin", + borderStyle: "@border-style.solid", + borderColor: "transparent", + borderRadius: "@border-radius.md", + paddingTop: "@0.5", + paddingBottom: "@0.5", + paddingLeft: "@0.75", + paddingRight: "@0.75", + background: "transparent", + color: "@color.text", + outline: "none", + transitionProperty: "color, background-color, border-color", + transitionTimingFunction: "@easing.ease-in-out", + transitionDuration: "150ms", + "&:focus-within": { + outlineWidth: "2px", + outlineStyle: "solid", + outlineColor: "@color.primary", + outlineOffset: "2px", + }, + }); + }); + + describe("variants", () => { + it("should have all color variants", () => { + const s = createInstance(); + const recipe = useInputDateRecipe(s); + + expect(Object.keys(recipe.variants!.color)).toEqual([ + "light", + "dark", + "neutral", + ]); + }); + + it("should have all style variants", () => { + const s = createInstance(); + const recipe = useInputDateRecipe(s); + + expect(Object.keys(recipe.variants!.variant)).toEqual([ + "default", + "soft", + "ghost", + ]); + }); + + it("should have size variants matching the field family", () => { + const s = createInstance(); + const recipe = useInputDateRecipe(s); + + expect(Object.keys(recipe.variants!.size)).toEqual(["sm", "md", "lg"]); + }); + + it("should have invalid boolean variants", () => { + const s = createInstance(); + const recipe = useInputDateRecipe(s); + + expect(recipe.variants!.invalid).toEqual({ true: {}, false: {} }); + }); + + it("should have disabled boolean variants", () => { + const s = createInstance(); + const recipe = useInputDateRecipe(s); + + expect(recipe.variants!.disabled).toEqual({ true: {}, false: {} }); + }); + + it("should have readonly boolean variants", () => { + const s = createInstance(); + const recipe = useInputDateRecipe(s); + + expect(recipe.variants!.readonly).toEqual({ true: {}, false: {} }); + }); + }); + + it("should have correct default variants", () => { + const s = createInstance(); + const recipe = useInputDateRecipe(s); + + expect(recipe.defaultVariants).toEqual({ + color: "neutral", + variant: "default", + size: "md", + invalid: "false", + disabled: "false", + readonly: "false", + }); + }); + + describe("compound variants", () => { + it("should have 12 compound variants total (9 color×variant + invalid + readonly + disabled)", () => { + const s = createInstance(); + const recipe = useInputDateRecipe(s); + + expect(recipe.compoundVariants).toHaveLength(12); + }); + + it("should share the neutral default surface with the input recipe", () => { + const s = createInstance(); + const recipe = useInputDateRecipe(s); + + const cv = recipe.compoundVariants!.find( + (v) => v.match.color === "neutral" && v.match.variant === "default", + ); + + expect(cv).toEqual({ + match: { color: "neutral", variant: "default" }, + css: { + background: "@color.white", + borderColor: "@color.gray-200", + color: "@color.text", + "&:hover": { + borderColor: "@color.gray-300", + }, + "&:dark": { + background: "@color.gray-900", + borderColor: "@color.gray-700", + color: "@color.white", + }, + "&:dark:hover": { + borderColor: "@color.gray-600", + }, + }, + }); + }); + + it("should override the focus-within ring when invalid", () => { + const s = createInstance(); + const recipe = useInputDateRecipe(s); + + const cv = recipe.compoundVariants!.find( + (v) => v.match.invalid === "true", + ); + + expect(cv).toEqual({ + match: { invalid: "true" }, + css: { + borderColor: "@color.error", + "&:hover": { + borderColor: "@color.error", + }, + "&:focus-within": { + outlineColor: "@color.error", + }, + "&:dark": { + borderColor: "@color.error", + }, + "&:dark:hover": { + borderColor: "@color.error", + }, + }, + }); + }); + + it("should dim and block interaction when disabled", () => { + const s = createInstance(); + const recipe = useInputDateRecipe(s); + + const cv = recipe.compoundVariants!.find( + (v) => v.match.disabled === "true", + ); + + expect(cv).toEqual({ + match: { disabled: "true" }, + css: { + opacity: "0.5", + cursor: "not-allowed", + pointerEvents: "none", + }, + }); + }); + }); + + describe("setup callback", () => { + it("should register a .input-date-field selector", () => { + const s = createInstance(); + useInputDateRecipe(s); + + const found = s.root.children.find( + (child) => + child.type === "selector" && + (child as { query: string }).query === ".input-date-field", + ); + + expect(found).toBeDefined(); + }); + + it("should register a .input-date-separator selector", () => { + const s = createInstance(); + useInputDateRecipe(s); + + const found = s.root.children.find( + (child) => + child.type === "selector" && + (child as { query: string }).query === ".input-date-separator", + ); + + expect(found).toBeDefined(); + }); + }); + + describe("filter", () => { + it("should filter color variants", () => { + const s = createInstance(); + const recipe = useInputDateRecipe(s, { + filter: { color: ["neutral"] }, + }); + + expect(Object.keys(recipe.variants!.color)).toEqual(["neutral"]); + }); + + it("should prune color-scoped compound variants when filtering colors", () => { + const s = createInstance(); + const recipe = useInputDateRecipe(s, { + filter: { color: ["neutral"] }, + }); + + expect( + recipe.compoundVariants!.every( + (cv) => cv.match.color === undefined || cv.match.color === "neutral", + ), + ).toBe(true); + expect(recipe.compoundVariants!.length).toBeLessThan(12); + }); + }); +}); diff --git a/theme/src/recipes/input-date/useInputDateRecipe.ts b/theme/src/recipes/input-date/useInputDateRecipe.ts new file mode 100644 index 00000000..05721a4f --- /dev/null +++ b/theme/src/recipes/input-date/useInputDateRecipe.ts @@ -0,0 +1,50 @@ +import { createFieldRecipe } from "../input/createFieldRecipe"; + +/** + * InputDate wrapper recipe. The `.input-date` class sits on a wrapper element + * that contains an optional inline `#prefix`/`#suffix` slot (reusing the Input + * prefix/suffix recipes for a leading calendar icon) and a nested + * `` that lays out the editable date/time + * segments (`.input-date-segment`) and separators (`.input-date-separator`). + * The wrapper owns the visual field (border, background, padding, focus ring via + * `:focus-within`); the inner segments are transparent and inherit typography. + * + * A member of the field recipes family — color (light, dark, neutral), variant + * (default, soft, ghost), size, and the `invalid` / `disabled` / `readonly` + * boolean axes — built from the shared field surface in + * `../input/createFieldRecipe`; the wrapper sits inline (inline-flex, vertically + * centered). Calendar/popover styling is out of scope (see the `calendar` + * recipe). The setup callback turns the shared `.input-date-field` reset into a + * horizontal flex row for the segments and registers the muted separator glyph. + */ +export const useInputDateRecipe = createFieldRecipe( + "input-date", + { + base: { + display: "inline-flex", + alignItems: "center", + }, + }, + (s) => { + const { selector } = s; + + // Lay the segments + separators out as a horizontal row inside the + // shared transparent field container. + selector(".input-date-field", { + display: "flex", + alignItems: "center", + gap: "@0.125", + }); + + // Muted, non-selectable date/range separator glyph (e.g. "/" or "–"). + selector(".input-date-separator", { + color: "@color.text-weak", + userSelect: "none", + paddingLeft: "@0.125", + paddingRight: "@0.125", + "&:dark": { + color: "@color.gray-400", + }, + }); + }, +); diff --git a/theme/src/recipes/input-date/useInputDateSegmentRecipe.test.ts b/theme/src/recipes/input-date/useInputDateSegmentRecipe.test.ts new file mode 100644 index 00000000..50f57cfa --- /dev/null +++ b/theme/src/recipes/input-date/useInputDateSegmentRecipe.test.ts @@ -0,0 +1,134 @@ +import { styleframe } from "@styleframe/core"; +import { useFocusModifier } from "../../modifiers/usePseudoStateModifiers"; +import { useInputDateSegmentRecipe } from "./useInputDateSegmentRecipe"; + +function createInstance() { + const s = styleframe(); + for (const name of [ + "display", + "alignItems", + "justifyContent", + "boxSizing", + "fontFamily", + "fontWeight", + "fontSize", + "lineHeight", + "fontVariantNumeric", + "paddingTop", + "paddingBottom", + "paddingLeft", + "paddingRight", + "borderRadius", + "background", + "color", + "outline", + "transitionProperty", + "transitionTimingFunction", + "transitionDuration", + ]) { + s.utility(name, ({ value }) => ({ [name]: value })); + } + useFocusModifier(s); + return s; +} + +describe("useInputDateSegmentRecipe", () => { + it("should create a recipe with correct metadata", () => { + const s = createInstance(); + const recipe = useInputDateSegmentRecipe(s); + + expect(recipe.type).toBe("recipe"); + expect(recipe.name).toBe("input-date-segment"); + }); + + it("should have transparent, centered base styles with a focus highlight", () => { + const s = createInstance(); + const recipe = useInputDateSegmentRecipe(s); + + expect(recipe.base).toEqual({ + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + boxSizing: "border-box", + fontFamily: "inherit", + fontWeight: "@font-weight.normal", + lineHeight: "@line-height.normal", + fontVariantNumeric: "tabular-nums", + paddingTop: "@0.125", + paddingBottom: "@0.125", + background: "transparent", + color: "inherit", + outline: "none", + transitionProperty: "color, background-color", + transitionTimingFunction: "@easing.ease-in-out", + transitionDuration: "150ms", + "&:focus": { + background: "@color.primary", + color: "@color.white", + }, + }); + }); + + it("should have size variants with scaled font, padding and radius", () => { + const s = createInstance(); + const recipe = useInputDateSegmentRecipe(s); + + expect(recipe.variants!.size).toEqual({ + sm: { + fontSize: "@font-size.xs", + paddingLeft: "@0.125", + paddingRight: "@0.125", + borderRadius: "@border-radius.sm", + }, + md: { + fontSize: "@font-size.sm", + paddingLeft: "@0.25", + paddingRight: "@0.25", + borderRadius: "@border-radius.sm", + }, + lg: { + fontSize: "@font-size.md", + paddingLeft: "@0.25", + paddingRight: "@0.25", + borderRadius: "@border-radius.md", + }, + }); + }); + + it("should have correct default variants", () => { + const s = createInstance(); + const recipe = useInputDateSegmentRecipe(s); + + expect(recipe.defaultVariants).toEqual({ size: "md" }); + }); + + it("should not declare a color or variant axis", () => { + const s = createInstance(); + const recipe = useInputDateSegmentRecipe(s); + + expect(recipe.variants).not.toHaveProperty("color"); + expect(recipe.variants).not.toHaveProperty("variant"); + }); + + describe("config overrides", () => { + it("should allow overriding base styles", () => { + const s = createInstance(); + const recipe = useInputDateSegmentRecipe(s, { + base: { justifyContent: "flex-start" }, + }); + + expect(recipe.base!.justifyContent).toBe("flex-start"); + }); + }); + + describe("filter", () => { + it("should filter size variants", () => { + const s = createInstance(); + const recipe = useInputDateSegmentRecipe(s, { + filter: { size: ["md"] }, + }); + + expect(Object.keys(recipe.variants!.size)).toEqual(["md"]); + }); + }); +}); diff --git a/theme/src/recipes/input-date/useInputDateSegmentRecipe.ts b/theme/src/recipes/input-date/useInputDateSegmentRecipe.ts new file mode 100644 index 00000000..59b5b889 --- /dev/null +++ b/theme/src/recipes/input-date/useInputDateSegmentRecipe.ts @@ -0,0 +1,65 @@ +import { createUseRecipe } from "../../utils/createUseRecipe"; + +/** + * InputDate segment recipe. The `.input-date-segment` class sits on each + * individually-focusable date/time field part (day, month, year, hour, minute) + * rendered inside the `.input-date-field` row. Like the OTP cell it is the + * focusable element itself, but unlike the cell it is inline and unboxed: it is + * transparent, inherits typography and color from the wrapper, centers its + * digits with tabular figures, and only paints a highlight on `:focus`. + * + * Carries no color/variant axis — the surface lives on the `.input-date` + * wrapper. Its single `size` axis scales the font, horizontal padding, and + * corner radius so the segment tracks the wrapper size. The focus highlight uses + * absolute tokens (`@color.primary` background, `@color.white` text) so it reads + * correctly in both light and dark mode. + */ +export const useInputDateSegmentRecipe = createUseRecipe("input-date-segment", { + base: { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + boxSizing: "border-box", + fontFamily: "inherit", + fontWeight: "@font-weight.normal", + lineHeight: "@line-height.normal", + fontVariantNumeric: "tabular-nums", + paddingTop: "@0.125", + paddingBottom: "@0.125", + background: "transparent", + color: "inherit", + outline: "none", + transitionProperty: "color, background-color", + transitionTimingFunction: "@easing.ease-in-out", + transitionDuration: "150ms", + "&:focus": { + background: "@color.primary", + color: "@color.white", + }, + }, + variants: { + size: { + sm: { + fontSize: "@font-size.xs", + paddingLeft: "@0.125", + paddingRight: "@0.125", + borderRadius: "@border-radius.sm", + }, + md: { + fontSize: "@font-size.sm", + paddingLeft: "@0.25", + paddingRight: "@0.25", + borderRadius: "@border-radius.sm", + }, + lg: { + fontSize: "@font-size.md", + paddingLeft: "@0.25", + paddingRight: "@0.25", + borderRadius: "@border-radius.md", + }, + }, + }, + defaultVariants: { + size: "md", + }, +}); From 4c3138caa1de62f1bb336a7f8d78387243419370 Mon Sep 17 00:00:00 2001 From: Alex Grozav Date: Wed, 24 Jun 2026 11:47:40 +0300 Subject: [PATCH 2/3] fix(theme): resolve undefined "0.125" variable in input-date setup Raw selector() declarations don't auto-declare numeric spacing multiplier variables the way recipe base/variant values do, so referencing "@0.125" in the input-date wrapper setup threw "Variable 0.125 is not defined" at Storybook build time. Inline the calc against the spacing base variable instead, matching the calendar recipe pattern. Update the test instance to declare spacing. Refs UXF-2. --- .../recipes/input-date/useInputDateRecipe.test.ts | 2 +- theme/src/recipes/input-date/useInputDateRecipe.ts | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/theme/src/recipes/input-date/useInputDateRecipe.test.ts b/theme/src/recipes/input-date/useInputDateRecipe.test.ts index 424b2d43..5be6aba4 100644 --- a/theme/src/recipes/input-date/useInputDateRecipe.test.ts +++ b/theme/src/recipes/input-date/useInputDateRecipe.test.ts @@ -49,7 +49,7 @@ function createInstance() { s.variable("color.text-weakest", "#64748B", { default: true }); s.variable("color.text-weak", "#475569", { default: true }); s.variable("color.gray-400", "#94A3B8", { default: true }); - s.variable("0.125", "0.125rem", { default: true }); + s.variable("spacing", "1rem", { default: true }); useDarkModifier(s); useHoverModifier(s); useFocusWithinModifier(s); diff --git a/theme/src/recipes/input-date/useInputDateRecipe.ts b/theme/src/recipes/input-date/useInputDateRecipe.ts index 05721a4f..64a4d597 100644 --- a/theme/src/recipes/input-date/useInputDateRecipe.ts +++ b/theme/src/recipes/input-date/useInputDateRecipe.ts @@ -26,22 +26,27 @@ export const useInputDateRecipe = createFieldRecipe( }, }, (s) => { - const { selector } = s; + const { selector, css, ref } = s; + + // 0.125 of the base spacing step. Inlined as a calc rather than referenced + // as "@0.125" because raw selectors don't auto-declare numeric multiplier + // variables (same pattern as the calendar recipe). + const spacingHalfQuarter = css`calc(${ref("spacing")} * 0.125)`; // Lay the segments + separators out as a horizontal row inside the // shared transparent field container. selector(".input-date-field", { display: "flex", alignItems: "center", - gap: "@0.125", + gap: spacingHalfQuarter, }); // Muted, non-selectable date/range separator glyph (e.g. "/" or "–"). selector(".input-date-separator", { color: "@color.text-weak", userSelect: "none", - paddingLeft: "@0.125", - paddingRight: "@0.125", + paddingLeft: spacingHalfQuarter, + paddingRight: spacingHalfQuarter, "&:dark": { color: "@color.gray-400", }, From efb88d1d8e56fb3daeb7b308efa117240a0b62f7 Mon Sep 17 00:00:00 2001 From: Alex Grozav Date: Wed, 24 Jun 2026 12:07:56 +0300 Subject: [PATCH 3/3] refactor(theme): simplify input-date spacing to calc(@spacing * 0.125) Replace the verbose css`calc(${ref("spacing")} * 0.125)` setup-selector form with the embedded-reference expression `calc(@spacing * 0.125)`. Styleframe's resolver parses an @-ref embedded in an expression without validation, so a raw selector can scale a token inline without a pre-declared multiplier variable. The arithmetic must stay inside calc() or the output is invalid CSS (var(--spacing) * .125). Document the pattern in the implement-recipe and verify-recipe skills, and promote the Storybook production build to a required verification step (it is the only check that catches undefined-token errors). Refs UXF-2. --- .claude/skills/implement-recipe/SKILL.md | 34 +++++++++++++++++++ .claude/skills/verify-recipe/SKILL.md | 26 ++++++++++---- .../recipes/input-date/useInputDateRecipe.ts | 18 +++++----- 3 files changed, 62 insertions(+), 16 deletions(-) diff --git a/.claude/skills/implement-recipe/SKILL.md b/.claude/skills/implement-recipe/SKILL.md index 7d9ff32b..aa8585b3 100644 --- a/.claude/skills/implement-recipe/SKILL.md +++ b/.claude/skills/implement-recipe/SKILL.md @@ -543,6 +543,40 @@ export const useSpinnerRecipe = createUseRecipe( ); ``` +#### Spacing multipliers inside a `setup` callback — use `calc(@token * N)` + +Bare numeric spacing refs like `@0.125` / `@0.25` are **multiplier tokens** that +resolve to a variable *named* `"0.125"`. In a recipe's `base` / `variants` these are +auto-declared on the fly (e.g. spinner/badge size variants use `@0.25`, `@0.5`). But +raw `selector(...)` calls inside a `setup` callback only **reference** — they do NOT +auto-declare. Referencing `@0.125` in a setup selector throws at Storybook build init: +`Variable "0.125" is not defined` (typecheck + theme tests do NOT catch this). + +To scale a token by a fraction inside a setup selector, use an **embedded reference +expression wrapped in `calc()`** as a plain string: + +```ts +(s) => { + const { selector } = s; + selector(".input-date-field", { + gap: "calc(@spacing * 0.125)", // → gap: calc(var(--spacing) * .125) + }); +} +``` + +Styleframe's resolver (`engine/core/src/tokens/resolve.ts`) treats an *exact* `@name` +match as a validated reference (throws if undefined) but parses an *embedded* expression +like `@spacing * 0.125` without validation — it resolves `@spacing` and keeps the rest +literal. This is the same pattern used by dropdown/tooltip/popover (`"calc(@tooltip.arrow.size * -1)"`). + +- **Keep the arithmetic inside `calc(...)`.** Bare `"@spacing * 0.125"` parses but emits + invalid CSS `gap:var(--spacing) * .125` (no `calc` → the browser drops the declaration). +- **Test gotcha:** in the recipe test instance, declare `s.variable("spacing", "1rem")` and + let the calc resolve — do NOT hand-declare a fake `s.variable("0.125", ...)`, which masks + the real build failure. +- **Always run `pnpm --filter @styleframe/storybook build` during verification** — it is the + only check that exercises real token resolution in a fresh instance. + --- ### Deliverable 2: Barrel index diff --git a/.claude/skills/verify-recipe/SKILL.md b/.claude/skills/verify-recipe/SKILL.md index 385f2f07..1c4037fc 100644 --- a/.claude/skills/verify-recipe/SKILL.md +++ b/.claude/skills/verify-recipe/SKILL.md @@ -68,15 +68,25 @@ Parse output. Capture: - Exit status. - For each issue: file:line, rule, message. -### Step 3: (Optional) Storybook spot-check - -If the user wants, offer to start Storybook briefly to verify the story renders: +### Step 3: Storybook build (required) ```bash -pnpm storybook +pnpm --filter @styleframe/storybook build ``` -This is a manual check — the assistant cannot verify rendering automatically. Prompt the user to open the component story in the browser and confirm it looks correct. +Run the **production build**, not just the dev server. This is the **only** check that +exercises real token resolution in a fresh Styleframe instance — typecheck and theme +tests pass even when a recipe references an undefined variable. A `setup` callback that +references an undeclared spacing multiplier (e.g. a raw `selector` using `@0.125`) throws +here with `Variable "0.125" is not defined`; the fix lives in `/implement-recipe` (use +`calc(@spacing * 0.125)` instead — see that skill's Step 12). Capture exit status and any +`Variable "..." is not defined` errors with the originating recipe file. + +#### (Optional) Storybook render spot-check + +If the user wants a visual check, offer to start Storybook (`pnpm storybook`) — but this is +manual; the assistant cannot verify rendering automatically. Prompt the user to open the +story and confirm it looks correct. ### Step 4: Write `verification.md` @@ -115,7 +125,10 @@ Do NOT attempt to fix issues automatically. ## Lint: -## Storybook spot-check: +## Storybook build: + + +## Storybook render spot-check: ## Summary - Total failures: @@ -129,6 +142,7 @@ Do NOT attempt to fix issues automatically. - [ ] `pnpm typecheck` was run from the project root. - [ ] `pnpm --filter @styleframe/theme test` was run. - [ ] `pnpm lint` was run. +- [ ] `pnpm --filter @styleframe/storybook build` was run (catches token-resolution errors). - [ ] Each failure is reported with file:line and a suggested sub-skill. - [ ] `verification.md` is written to `.context/recipe-/`. - [ ] User received a short pass/fail summary. diff --git a/theme/src/recipes/input-date/useInputDateRecipe.ts b/theme/src/recipes/input-date/useInputDateRecipe.ts index 64a4d597..1e60fb43 100644 --- a/theme/src/recipes/input-date/useInputDateRecipe.ts +++ b/theme/src/recipes/input-date/useInputDateRecipe.ts @@ -26,27 +26,25 @@ export const useInputDateRecipe = createFieldRecipe( }, }, (s) => { - const { selector, css, ref } = s; - - // 0.125 of the base spacing step. Inlined as a calc rather than referenced - // as "@0.125" because raw selectors don't auto-declare numeric multiplier - // variables (same pattern as the calendar recipe). - const spacingHalfQuarter = css`calc(${ref("spacing")} * 0.125)`; + const { selector } = s; // Lay the segments + separators out as a horizontal row inside the - // shared transparent field container. + // shared transparent field container. "calc(@spacing * 0.125)" is an + // embedded reference expression: Styleframe parses the "@spacing" ref and + // keeps the surrounding calc arithmetic, so a raw selector can scale a + // token inline without a pre-declared "0.125" multiplier variable. selector(".input-date-field", { display: "flex", alignItems: "center", - gap: spacingHalfQuarter, + gap: "calc(@spacing * 0.125)", }); // Muted, non-selectable date/range separator glyph (e.g. "/" or "–"). selector(".input-date-separator", { color: "@color.text-weak", userSelect: "none", - paddingLeft: spacingHalfQuarter, - paddingRight: spacingHalfQuarter, + paddingLeft: "calc(@spacing * 0.125)", + paddingRight: "calc(@spacing * 0.125)", "&:dark": { color: "@color.gray-400", },