@lotics/ui 43.4.0 → 43.5.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.
Files changed (68) hide show
  1. package/AGENTS.md +3 -2
  2. package/MIGRATION.md +93 -0
  3. package/docs/catalog.md +128 -26
  4. package/docs/composition.md +380 -9
  5. package/docs/data_entry.md +25 -0
  6. package/docs/reviewing.md +477 -0
  7. package/docs/templates.md +9 -6
  8. package/examples/tpl_attendance.tsx +0 -1
  9. package/examples/tpl_item_list.tsx +109 -54
  10. package/examples/tpl_record.tsx +89 -4
  11. package/package.json +7 -3
  12. package/src/avatar.tsx +29 -29
  13. package/src/avatar.web.tsx +32 -31
  14. package/src/avatar_props.ts +66 -0
  15. package/src/avatar_tone.ts +79 -0
  16. package/src/button.tsx +36 -4
  17. package/src/checkbox.tsx +4 -1
  18. package/src/choice_list.tsx +5 -3
  19. package/src/color_tokens.ts +15 -14
  20. package/src/composer.tsx +3 -3
  21. package/src/control_surface.ts +37 -9
  22. package/src/copy_button.tsx +8 -1
  23. package/src/counter.tsx +1 -1
  24. package/src/data_grid.tsx +6 -3
  25. package/src/date_calendar.tsx +4 -2
  26. package/src/date_range_filter_field.tsx +5 -1
  27. package/src/date_segments_field.tsx +2 -2
  28. package/src/file_drop_target.web.tsx +2 -2
  29. package/src/file_dropzone.tsx +12 -6
  30. package/src/file_rows.tsx +22 -2
  31. package/src/file_thumbnail.tsx +19 -3
  32. package/src/font_family.ts +1 -1
  33. package/src/font_family.web.ts +1 -1
  34. package/src/funnel.tsx +1 -1
  35. package/src/icon_button.tsx +5 -2
  36. package/src/index.css +22 -16
  37. package/src/inline_edit.tsx +9 -10
  38. package/src/inline_files.tsx +6 -0
  39. package/src/json_panel.tsx +1 -1
  40. package/src/kpi_card.tsx +1 -1
  41. package/src/locale.tsx +1 -1
  42. package/src/markdown.css +5 -1
  43. package/src/metric.tsx +12 -18
  44. package/src/number_input.tsx +2 -2
  45. package/src/option_picker.tsx +57 -0
  46. package/src/picker.tsx +2 -2
  47. package/src/pressable_row.tsx +15 -5
  48. package/src/progress_bar.tsx +1 -1
  49. package/src/radio_picker.tsx +2 -1
  50. package/src/search_input.tsx +12 -11
  51. package/src/sort_header.tsx +7 -3
  52. package/src/stacked_progress_bar.tsx +1 -1
  53. package/src/step_progress.tsx +5 -2
  54. package/src/switch.tsx +11 -6
  55. package/src/table.tsx +148 -16
  56. package/src/table_fit.ts +12 -0
  57. package/src/tabs.tsx +13 -1
  58. package/src/text.css +50 -18
  59. package/src/text.tsx +37 -40
  60. package/src/text_input_field.tsx +2 -2
  61. package/src/text_utils.ts +48 -6
  62. package/src/theme.ts +13 -0
  63. package/src/theme.web.ts +49 -0
  64. package/src/theme_vars.ts +113 -0
  65. package/src/type_ramp.ts +100 -0
  66. package/src/theme.tsx +0 -24
  67. package/src/theme.web.tsx +0 -79
  68. package/src/theme_context.ts +0 -107
@@ -0,0 +1,49 @@
1
+ import { themeDeclarations, type LoticsTheme } from "./theme_vars";
2
+
3
+ export { THEME_VARS, deriveAccentWash, type LoticsTheme } from "./theme_vars";
4
+
5
+ /**
6
+ * Give this app its own identity — call ONCE at boot, before `mount`.
7
+ *
8
+ * ```ts
9
+ * applyLoticsTheme({ primary: "#0F766E", bodyFont: "'Be Vietnam Pro', sans-serif" });
10
+ * ```
11
+ *
12
+ * THE ESCAPE HATCH, and deliberately shaped like one. Theming is not a product
13
+ * surface: nothing in the platform writes a theme, there is no settings screen
14
+ * for it, and `apps.theme.color` is the launcher icon's colour and nothing else.
15
+ * This exists because an app occasionally needs its own typeface or brand, and
16
+ * the alternative — passing `style` at every call site — is a fork rather than a
17
+ * theme.
18
+ *
19
+ * Every kit surface follows, because `colors.web.ts` reads each role through a
20
+ * CSS variable with the kit's own value as the inline fallback. Setting one role
21
+ * overrides that role and nothing else.
22
+ *
23
+ * THE DOCUMENT ROOT, which is the point rather than an implementation detail. A
24
+ * custom property inherits down the DOM tree, and `Modal` (react-native-web)
25
+ * mounts its content into `document.body` — so a theme scoped to a React subtree
26
+ * reaches the register and not the `Drawer` opened from it. Nothing errors; the
27
+ * two halves of the app simply disagree, and the seam is visible only with both
28
+ * on screen at once.
29
+ *
30
+ * Called BEFORE `mount`, the variables are in place for the first paint, so no
31
+ * frame renders in the kit's defaults first.
32
+ *
33
+ * Idempotent — call it again with different values and the app re-themes, which
34
+ * is what a tenant switch or a live preview needs. There is nothing to unwind,
35
+ * which is why this is a function rather than a component: the provider it
36
+ * replaces spent a memo, a wrapper element, a layout effect and a
37
+ * restore-on-unmount serving a React lifecycle that an app setting one identity
38
+ * at boot never had.
39
+ *
40
+ * A no-op where there is no document (SSR, a test without jsdom), so a call at
41
+ * module scope is safe.
42
+ */
43
+ export function applyLoticsTheme(theme: LoticsTheme): void {
44
+ if (typeof document === "undefined") return;
45
+ const root = document.documentElement;
46
+ for (const [name, value] of Object.entries(themeDeclarations(theme))) {
47
+ root.style.setProperty(name, value);
48
+ }
49
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * THE THEME CONTRACT — the roles an app may own, and the CSS custom properties
3
+ * they resolve through.
4
+ *
5
+ * This is data, not machinery. `colors.web.ts` reads each variable with an
6
+ * inline fallback, so a role nobody sets answers with the kit's own value —
7
+ * which is what makes a theme ADDITIVE: setting one role overrides that role and
8
+ * nothing else.
9
+ *
10
+ * The variables are part of the published contract, so an app CAN declare them
11
+ * in its own stylesheet. Prefer `applyLoticsTheme`, which gets the two derived
12
+ * values right — see `deriveAccentWash` and the font fan-out below.
13
+ */
14
+
15
+ /** The roles an app may own. See `color_tokens.ts` for why exactly these. */
16
+ export interface LoticsTheme {
17
+ /** The one brand hue. It marks WHICH OF THESE — a selected row, an active
18
+ * filter — never a status, a series or a valence, which come from a palette
19
+ * family and carry meaning. */
20
+ accent?: string;
21
+ /** The surface Card, Drawer and Modal paint. Left white on almost every app;
22
+ * set it only for a deliberately toned surface. */
23
+ background?: string;
24
+ /** Hairlines. A register is mostly these lines, so a small change here is felt
25
+ * against a whole screen. */
26
+ border?: string;
27
+ /**
28
+ * THE TYPEFACE, for everything. One value — a CSS font stack — and the whole
29
+ * app follows it, because it fans out to the three weight rungs
30
+ * (`font_family.ts`): weight is a FAMILY in this kit, not a `font-weight`.
31
+ *
32
+ * Pass a stack you know RESOLVES — a face nobody `@font-face`d falls silently
33
+ * through to system sans, which is the one failure here that looks like
34
+ * nothing happening. And check Vietnamese: a face without the diacritics turns
35
+ * "Thẩm định" into tofu. The kit's letter-spacing curve is tuned for Inter's
36
+ * glyphs, so another face inherits tracking chosen for a different drawing —
37
+ * a refinement to check, not a blocker.
38
+ */
39
+ bodyFont?: string;
40
+ /**
41
+ * The typeface for DISPLAY text only — what `<Text family="display">` renders
42
+ * in. Defaults to `bodyFont` when that is set, so ONE parameter changes the
43
+ * whole app and a second is needed only to make display type differ from body.
44
+ */
45
+ displayFont?: string;
46
+ /** The ink that ACTS — the primary button's fill, a checkbox's fill, a
47
+ * switch's track, an active tab's rail. Defaults to the kit's near-black. */
48
+ primary?: string;
49
+ }
50
+
51
+ /** Role → the CSS custom property `colors.web.ts` reads for it. */
52
+ export const THEME_VARS: Record<keyof LoticsTheme, string> = {
53
+ accent: "--lotics-accent",
54
+ background: "--lotics-background",
55
+ border: "--lotics-border",
56
+ primary: "--lotics-primary",
57
+ bodyFont: "--lotics-font-regular",
58
+ displayFont: "--lotics-display-font",
59
+ };
60
+
61
+ /** The extra variable that is DERIVED — not a role an app sets, because it is
62
+ * the accent seen through alpha and two sources for one colour drift. */
63
+ export const ACCENT_WASH_VAR = "--lotics-accent-wash";
64
+
65
+ /** The two rungs `bodyFont` fans out to beyond its own `THEME_VARS` entry. */
66
+ export const FONT_MEDIUM_VAR = "--lotics-font-medium";
67
+ export const FONT_SEMIBOLD_VAR = "--lotics-font-semibold";
68
+
69
+ /**
70
+ * The accent at wash strength.
71
+ *
72
+ * Computed from the literal hex the app passed, because it CANNOT be computed
73
+ * downstream: on web the token is a `var()`, and the kit's alpha helpers parse
74
+ * `rgba()` strings. Returns undefined for a value it cannot parse (a named
75
+ * colour, an `oklch()`), so an unparseable accent falls back to the neutral wash
76
+ * rather than painting rows in garbage.
77
+ */
78
+ export function deriveAccentWash(accent: string): string | undefined {
79
+ const m = /^#([0-9a-f]{6})$/i.exec(accent.trim());
80
+ if (!m) return undefined;
81
+ const n = parseInt(m[1], 16);
82
+ // 0.07 — heavy enough to read as a tint on white, light enough that body text
83
+ // on top keeps its contrast; the neutral it replaces (zinc-100) sits about here.
84
+ return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, 0.07)`;
85
+ }
86
+
87
+ /**
88
+ * The theme as CSS declarations — the role variables plus the two things an app
89
+ * writing raw CSS has to remember and usually will not.
90
+ *
91
+ * Shared by both platforms' `applyLoticsTheme` so the mapping has one
92
+ * definition; only a role actually PASSED appears.
93
+ */
94
+ export function themeDeclarations(theme: LoticsTheme): Record<string, string> {
95
+ const vars: Record<string, string> = {};
96
+ for (const [role, name] of Object.entries(THEME_VARS)) {
97
+ const value = theme[role as keyof LoticsTheme];
98
+ if (value !== undefined) vars[name] = value;
99
+ }
100
+ if (theme.accent !== undefined) {
101
+ const wash = deriveAccentWash(theme.accent);
102
+ if (wash !== undefined) vars[ACCENT_WASH_VAR] = wash;
103
+ }
104
+ // ONE font value, THREE rungs. `THEME_VARS` maps `bodyFont` to the regular
105
+ // rung; medium and semibold follow it here, because a family set on only one
106
+ // rung leaves every medium and semibold run in Inter — the screen comes out in
107
+ // two typefaces and reads as a font that half-loaded.
108
+ if (theme.bodyFont !== undefined) {
109
+ vars[FONT_MEDIUM_VAR] = theme.bodyFont;
110
+ vars[FONT_SEMIBOLD_VAR] = theme.bodyFont;
111
+ }
112
+ return vars;
113
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * THE TYPE RAMP — the one place the two curves are written down.
3
+ *
4
+ * `Text` renders through two entirely different mechanisms: on web the size is a
5
+ * `data-text-size` attribute matched by `text.css`, on native it is a
6
+ * `StyleSheet` entry. Those were two hand-maintained tables, and they drifted
7
+ * exactly as far as you would expect — by the time this file was written native
8
+ * disagreed with web on the leading of three rungs and on the tracking of all
9
+ * seven, because every typographic change since had been made on the web side
10
+ * only. The mobile app was running a ramp nobody had looked at.
11
+ *
12
+ * So the ramp is DATA here, and both consumers derive from it. `text.tsx` builds
13
+ * its native sheet from these numbers, so no second native table exists to drift.
14
+ * `text.css` cannot read TypeScript and stays hand-written — it needs the media
15
+ * query and it carries the reasoning a reader needs — so `type_ramp.test.ts`
16
+ * parses it and fails when a value disagrees with this table. Eliminate the
17
+ * duplication where you can; test it where you cannot.
18
+ *
19
+ * LEADING: the body rungs carry PROSE leading — 14/24, ratio 1.71, exactly what
20
+ * `.ui-markdown` uses. The ladder tightens as it rises, which is the convention:
21
+ * large type needs proportionally less leading, so the display rungs sit near
22
+ * 1.2 and only the rungs carrying running text are generous. The cost is real
23
+ * and deliberate — line-height sets the BOX height of even a single line, so
24
+ * every label, value and control gained about 4px of line box. Density was
25
+ * traded for rhythm.
26
+ *
27
+ * TRACKING: negative throughout, steepening as the type grows. The body rung is
28
+ * ANCHORED on -0.028em — the -0.4px this product already set on `.ui-markdown`
29
+ * and on its inputs, expressed as an em so it scales. That value is the
30
+ * product's own, arrived at by reading it in Vietnamese every day, and the rest
31
+ * of the ramp is derived FROM it rather than imported from another design
32
+ * system. Inter is drawn generously at default tracking, so untracked body reads
33
+ * loose beside it, and what looks right at 14px looks slack at 32px.
34
+ *
35
+ * `xs` is the one rung held back (-0.02em rather than the full body value). 12px
36
+ * is where Vietnamese stacked diacritics (ự, ộ, ế, ệ) and Inter's `1`/`l`/`i`
37
+ * come closest to colliding, and tightening is the direction that makes both
38
+ * worse.
39
+ *
40
+ * Tracking is stated ONCE, in em, and is not repeated per breakpoint: an em
41
+ * scales with the font size, so the same value is correct at both. Only the
42
+ * sizes and their leading differ across the breakpoint.
43
+ */
44
+
45
+ /** A rung's box, in px. */
46
+ export interface TypeRung {
47
+ size: number;
48
+ leading: number;
49
+ }
50
+
51
+ /**
52
+ * The ramp below 768px — and the ramp NATIVE uses, because a phone is a phone.
53
+ * The display rungs are the ones that grow on a desktop; body sits still.
54
+ */
55
+ export const TYPE_RAMP_MOBILE = {
56
+ xs: { size: 12, leading: 18 },
57
+ sm: { size: 14, leading: 24 },
58
+ md: { size: 16, leading: 26 },
59
+ lg: { size: 18, leading: 28 },
60
+ // Display ramp — the heading ladder: xl = `##` (section), xxl = `#`
61
+ // (page/record title), xxxl = hero numbers.
62
+ xl: { size: 22, leading: 28 },
63
+ xxl: { size: 28, leading: 34 },
64
+ xxxl: { size: 32, leading: 38 },
65
+ } as const satisfies Record<string, TypeRung>;
66
+
67
+ /** The ramp at 768px and above. Breakpoint per `use_screen_size`. */
68
+ export const TYPE_RAMP_DESKTOP = {
69
+ xs: { size: 12, leading: 18 },
70
+ sm: { size: 14, leading: 24 },
71
+ md: { size: 16, leading: 26 },
72
+ lg: { size: 20, leading: 30 },
73
+ xl: { size: 24, leading: 32 },
74
+ xxl: { size: 32, leading: 44 },
75
+ xxxl: { size: 48, leading: 52 },
76
+ } as const satisfies Record<keyof typeof TYPE_RAMP_MOBILE, TypeRung>;
77
+
78
+ /** Tracking per rung, in em. Negative at every rung — see the header. */
79
+ export const TYPE_TRACKING = {
80
+ xs: -0.02,
81
+ sm: -0.028,
82
+ md: -0.03,
83
+ lg: -0.032,
84
+ xl: -0.035,
85
+ xxl: -0.038,
86
+ xxxl: -0.04,
87
+ } as const satisfies Record<keyof typeof TYPE_RAMP_MOBILE, number>;
88
+
89
+ /** The rungs, in ladder order. `TextSize` in `text.tsx` is this set. */
90
+ export type TypeRungName = keyof typeof TYPE_RAMP_MOBILE;
91
+
92
+ /**
93
+ * The em tracking of a rung as px at a given size — what React Native needs,
94
+ * since `letterSpacing` there has no em unit and is a flat point value.
95
+ * Rounded to a thousandth so the number stays readable in a snapshot; the
96
+ * difference that discards is four orders of magnitude below a pixel.
97
+ */
98
+ export function trackingPx(rung: TypeRungName, size: number): number {
99
+ return Math.round(size * TYPE_TRACKING[rung] * 1000) / 1000;
100
+ }
package/src/theme.tsx DELETED
@@ -1,24 +0,0 @@
1
- import { type ReactNode } from "react";
2
- import { LoticsThemeContext, type LoticsTheme } from "./theme_context";
3
-
4
- export { DEFAULT_ACCENT, useLoticsTheme, THEME_VARS, type LoticsTheme } from "./theme_context";
5
-
6
- interface LoticsThemeProviderProps extends LoticsTheme {
7
- children: ReactNode;
8
- }
9
-
10
- /**
11
- * App-root provider — the NATIVE half.
12
- *
13
- * On native there are no CSS variables, and `colors` is a literal table, so
14
- * there is nothing to declare: the provider publishes the values for anything
15
- * that reads them through `useLoticsTheme()` and renders its children
16
- * untouched. The web half (`theme.web.tsx`) is where a theme actually paints.
17
- *
18
- * Deliberately NOT a `View`: a wrapper here would insert a layout box into every
19
- * themed app's tree on the one platform that gains nothing from it.
20
- */
21
- export function LoticsThemeProvider(props: LoticsThemeProviderProps) {
22
- const { children, ...theme } = props;
23
- return <LoticsThemeContext.Provider value={theme}>{children}</LoticsThemeContext.Provider>;
24
- }
package/src/theme.web.tsx DELETED
@@ -1,79 +0,0 @@
1
- import { useMemo, type CSSProperties, type ReactNode } from "react";
2
- import { ACCENT_WASH_VAR, deriveAccentWash, FONT_MEDIUM_VAR, FONT_SEMIBOLD_VAR, LoticsThemeContext, THEME_VARS, type LoticsTheme } from "./theme_context";
3
-
4
- export { DEFAULT_ACCENT, useLoticsTheme, THEME_VARS, type LoticsTheme } from "./theme_context";
5
-
6
- interface LoticsThemeProviderProps extends LoticsTheme {
7
- children: ReactNode;
8
- }
9
-
10
- /**
11
- * App-root provider that gives `@lotics/ui` primitives this app's identity:
12
- *
13
- * // src/main.tsx
14
- * <LoticsThemeProvider accent="#0F766E" border="#E3E8E6">
15
- * <App />
16
- * </LoticsThemeProvider>
17
- *
18
- * It declares the CSS custom properties `colors.web.ts` reads, so every kit
19
- * component below re-paints without knowing the provider exists. That
20
- * indirection is the point: the alternative — a context each primitive
21
- * subscribes to — would have to be threaded through 136 modules and would turn
22
- * their static stylesheets into per-render inline styles.
23
- *
24
- * **It renders a plain `div`, not a `View`, and that is load-bearing.**
25
- * react-native-web's style compiler only emits properties it knows; a custom
26
- * property handed to a `View` is silently DROPPED, so the variables never reach
27
- * the DOM and a themed app renders in the platform defaults with no error
28
- * anywhere. React DOM, by contrast, writes `--*` inline style keys through
29
- * verbatim. The failure is invisible rather than loud, which is exactly why the
30
- * two platforms get two files instead of one clever component.
31
- *
32
- * Only a role actually PASSED is declared, so an omitted one leaves its variable
33
- * undefined and `colors.web.ts`'s inline fallback answers. That is what makes
34
- * theming additive: a partial theme overrides a part, never resetting the rest
35
- * to some second set of defaults.
36
- */
37
- export function LoticsThemeProvider(props: LoticsThemeProviderProps) {
38
- const { children, ...theme } = props;
39
-
40
- // Keyed on the four values, not the object: the call site is normally an
41
- // inline literal, which would otherwise rebuild this style — and re-render
42
- // every kit surface under it — on each parent render.
43
- const style = useMemo(() => {
44
- const vars: Record<string, string> = {};
45
- for (const [role, name] of Object.entries(THEME_VARS)) {
46
- const value = theme[role as keyof LoticsTheme];
47
- if (value !== undefined) vars[name] = value;
48
- }
49
- // The wash is DERIVED here, where the accent is still a literal — see
50
- // `deriveAccentWash`. An accent the parser cannot read leaves it undefined,
51
- // so those surfaces keep the neutral default instead of breaking.
52
- if (theme.accent !== undefined) {
53
- const wash = deriveAccentWash(theme.accent);
54
- if (wash !== undefined) vars[ACCENT_WASH_VAR] = wash;
55
- }
56
- // ONE font value, THREE rungs. `THEME_VARS` already mapped `bodyFont` to the
57
- // regular rung; medium and semibold follow it here, because a family set on
58
- // only one rung would leave every medium and semibold run in Inter — the
59
- // screen would come out in two typefaces and look like a loading bug.
60
- if (theme.bodyFont !== undefined) {
61
- vars[FONT_MEDIUM_VAR] = theme.bodyFont;
62
- vars[FONT_SEMIBOLD_VAR] = theme.bodyFont;
63
- }
64
- // `display: contents` — the provider generates NO box. Custom properties
65
- // inherit down the DOM tree rather than the box tree, so the variables still
66
- // reach every descendant while the element itself adds no layout at all.
67
- // The first version was a flex column with `flex: 1`, which silently imposed
68
- // a layout contract on every app that wrapped its root in this: a provider
69
- // whose entire job is declaring three strings has no business deciding how its
70
- // children stack.
71
- return { display: "contents", ...vars } as CSSProperties;
72
- }, [theme.accent, theme.background, theme.border]);
73
-
74
- return (
75
- <div style={style}>
76
- <LoticsThemeContext.Provider value={theme}>{children}</LoticsThemeContext.Provider>
77
- </div>
78
- );
79
- }
@@ -1,107 +0,0 @@
1
- import { createContext, useContext } from "react";
2
- import { colors } from "./color_tokens";
3
-
4
- /**
5
- * The platform's own accent — what every kit surface wears unthemed. Same value
6
- * `colors.accent` falls back to; named so an app can reference the default
7
- * explicitly instead of re-typing a literal.
8
- */
9
- export const DEFAULT_ACCENT = colors.accent;
10
-
11
- /** The roles an app may own. See `color_tokens.ts` for why exactly these. */
12
- export interface LoticsTheme {
13
- /** The one brand hue. It paints IDENTITY marks — today the avatar's initials
14
- * disc — and deliberately NOT the focus ring or the primary action, which stay
15
- * neutral so those read the same in every app. Never a status, a series or a
16
- * valence: those come from a palette family and carry meaning. */
17
- accent?: string;
18
- /** The surface Card, Drawer and Modal paint — 21 sites in the kit. Left white
19
- * on almost every app; set it only for a deliberately toned surface. */
20
- background?: string;
21
- /** Hairlines — 48 sites in the kit, and a register is mostly these lines, so a
22
- * small change here is felt against a whole screen. */
23
- border?: string;
24
- /**
25
- * THE TYPEFACE, for everything. One value — a CSS font stack — and the whole
26
- * app follows it.
27
- *
28
- * It fans out to the three weight rungs (`font_family.ts`), because weight is
29
- * a FAMILY in this kit rather than a `font-weight`. Themed, all three resolve
30
- * to this family and the numeric weight already on every style makes the
31
- * ladder; unthemed, each keeps its own Inter face.
32
- *
33
- * Pass a stack you know RESOLVES — a face nobody `@font-face`d falls silently
34
- * through to system sans, which is the one failure here that looks like
35
- * nothing happening. And check Vietnamese: a face without the diacritics turns
36
- * "Thẩm định" into tofu.
37
- *
38
- * The kit's letter-spacing curve is tuned for Inter's glyphs, so another face
39
- * inherits tracking chosen for a different drawing. It is a refinement, not a
40
- * defect — worth an eye, not a blocker.
41
- */
42
- bodyFont?: string;
43
- /**
44
- * The typeface for DISPLAY text only — what `<Text family="display">` renders
45
- * in. A headline, a wordmark, a figure the page exists to show.
46
- *
47
- * Defaults to `bodyFont` when that is set, so ONE parameter changes the whole
48
- * app and a SECOND one is only needed to make display type differ from body —
49
- * a serif masthead over a sans register, say.
50
- */
51
- displayFont?: string;
52
- /** The PRIMARY action's fill. Defaults to the kit's near-black, so an app that
53
- * themes nothing is unchanged; set it to put the brand on the one filled
54
- * button a surface gets. Distinct from `accent`, which marks identity. */
55
- primary?: string;
56
- }
57
-
58
- /**
59
- * The CSS custom properties `colors.web.ts` reads. Part of the published
60
- * contract: an app may set them from its own stylesheet instead of the provider.
61
- */
62
- export const THEME_VARS: Record<keyof LoticsTheme, string> = {
63
- accent: "--lotics-accent",
64
- background: "--lotics-background",
65
- border: "--lotics-border",
66
- primary: "--lotics-primary",
67
- bodyFont: "--lotics-font-regular",
68
- displayFont: "--lotics-display-font",
69
- };
70
-
71
- /** The extra variable the provider DERIVES — not a role an app sets, because it
72
- * is the accent seen through alpha and two sources for one colour drift. */
73
- export const ACCENT_WASH_VAR = "--lotics-accent-wash";
74
-
75
- /** The two rungs `bodyFont` fans out to beyond its own `THEME_VARS` entry. */
76
- export const FONT_MEDIUM_VAR = "--lotics-font-medium";
77
- export const FONT_SEMIBOLD_VAR = "--lotics-font-semibold";
78
-
79
- /**
80
- * The accent at wash strength.
81
- *
82
- * Computed from the literal hex the app passed, because it CANNOT be computed
83
- * downstream: on web the token is a `var()`, and the kit's alpha helpers parse
84
- * `rgba()` strings. Returns undefined for a value it cannot parse (a named
85
- * colour, an `oklch()`), so an unparseable accent falls back to the neutral wash
86
- * rather than painting rows in garbage.
87
- */
88
- export function deriveAccentWash(accent: string): string | undefined {
89
- const hex = accent.trim();
90
- const m = /^#([0-9a-f]{6})$/i.exec(hex);
91
- if (!m) return undefined;
92
- const n = parseInt(m[1], 16);
93
- // 0.07 — heavy enough to read as a tint on white, light enough that body text
94
- // on top keeps its contrast; the neutral it replaces (zinc-100) sits about here.
95
- return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, 0.07)`;
96
- }
97
-
98
- export const LoticsThemeContext = createContext<LoticsTheme>({});
99
-
100
- /**
101
- * Read the current theme. An app SETS values through the provider's props; this
102
- * is for a surface that must branch on one (a chart picking a fill). A role the
103
- * app did not set reads `undefined` — ask `colors.<role>` for what will paint.
104
- */
105
- export function useLoticsTheme(): LoticsTheme {
106
- return useContext(LoticsThemeContext);
107
- }