@mythicalos/ui-core 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -4,3 +4,6 @@ export { typedNameMatches, BULLET_ICON, type DialogBullet } from "./logic/dialog
4
4
  export { composeToastText, TOAST_SEP, type ToastStatus, type ToastSpec, type ToastBus, } from "./logic/toast.js";
5
5
  export { gaugeTone, gaugeGeom, type GaugeGeom } from "./logic/gauge.js";
6
6
  export { chipClass, statusLineClass, bannerClass, BANNER_ICON, type Tone, type ChipTone, type StatusTone, type BannerTone, } from "./logic/tone.js";
7
+ export { saveBarNote, saveBarDirty, saveBarClass, SAVE_BAR_PARTS, SAVE_BAR_SEP, SAVE_BAR_DISCARD_LABEL, SAVE_BAR_SAVE_LABEL, type SaveBarNote, } from "./logic/save-bar.js";
8
+ export { statTilesClass, statTileClass, STAT_TILE_PARTS, formatStatCompact, formatStatCount, formatStatUsd, formatStatPercent, STAT_TILE_EMPTY, STAT_TILE_MINUS, type StatTile, type StatTileTone, } from "./logic/stat-tiles.js";
9
+ export { gitFlags, hasGitStatus, gitBranchLabel, gitChipClass, gitFlagClass, gitChipNote, GIT_CHIP_PARTS, GIT_BRANCH_GLYPH, GIT_BRANCH_UNKNOWN, GIT_DETACHED_LABEL, GIT_CLEAN_LABEL, GIT_LOADING_NOTE, GIT_UNAVAILABLE_NOTE, GIT_STALE_LABEL, GIT_STALE_TITLE, type GitStatus, type GitFlag, type GitFlagTone, type GitChipState, } from "./logic/git-chip.js";
package/dist/index.js CHANGED
@@ -7,3 +7,7 @@ export { typedNameMatches, BULLET_ICON } from "./logic/dialog.js";
7
7
  export { composeToastText, TOAST_SEP, } from "./logic/toast.js";
8
8
  export { gaugeTone, gaugeGeom } from "./logic/gauge.js";
9
9
  export { chipClass, statusLineClass, bannerClass, BANNER_ICON, } from "./logic/tone.js";
10
+ // ── small atoms graduated out of a single product: save-bar, stat-tiles, git-chip ──
11
+ export { saveBarNote, saveBarDirty, saveBarClass, SAVE_BAR_PARTS, SAVE_BAR_SEP, SAVE_BAR_DISCARD_LABEL, SAVE_BAR_SAVE_LABEL, } from "./logic/save-bar.js";
12
+ export { statTilesClass, statTileClass, STAT_TILE_PARTS, formatStatCompact, formatStatCount, formatStatUsd, formatStatPercent, STAT_TILE_EMPTY, STAT_TILE_MINUS, } from "./logic/stat-tiles.js";
13
+ export { gitFlags, hasGitStatus, gitBranchLabel, gitChipClass, gitFlagClass, gitChipNote, GIT_CHIP_PARTS, GIT_BRANCH_GLYPH, GIT_BRANCH_UNKNOWN, GIT_DETACHED_LABEL, GIT_CLEAN_LABEL, GIT_LOADING_NOTE, GIT_UNAVAILABLE_NOTE, GIT_STALE_LABEL, GIT_STALE_TITLE, } from "./logic/git-chip.js";
@@ -0,0 +1,79 @@
1
+ /** Flag tone. Per the design card: warn = behind / uncommitted, error = unpushed ("a real error
2
+ * state"), ok = clean. */
3
+ export type GitFlagTone = "warn" | "error" | "ok";
4
+ export interface GitFlag {
5
+ label: string;
6
+ tone: GitFlagTone;
7
+ }
8
+ /** A reported git status. Every field is REQUIRED so a caller cannot half-report a tree and get a
9
+ * confident render — but each is also runtime-guarded, because a JS consumer can still pass
10
+ * `undefined`, and "not reported" must degrade to honest silence, never to a fabricated zero. */
11
+ export interface GitStatus {
12
+ /** Branch name, or `null` for a detached HEAD. */
13
+ branch: string | null;
14
+ /** Commits behind upstream. `null` ⇒ there is NO upstream — the flag is omitted, not zeroed. */
15
+ behind: number | null;
16
+ /** Uncommitted working-tree changes. */
17
+ uncommitted: number;
18
+ /** Commits not pushed to upstream. `null` ⇒ there is NO upstream — the flag is omitted. */
19
+ unpushed: number | null;
20
+ }
21
+ /** The branch glyph the card prefixes the name with. */
22
+ export declare const GIT_BRANCH_GLYPH = "\u2387";
23
+ /** Shown in place of a branch name when there is no status to show at all. */
24
+ export declare const GIT_BRANCH_UNKNOWN = "\u2014";
25
+ /** A `null` branch is a real, nameable state — never a blank. */
26
+ export declare const GIT_DETACHED_LABEL = "detached HEAD";
27
+ /** The all-clear flag (token rule #7: the ✓ glyph rides along with the ok-soft color). */
28
+ export declare const GIT_CLEAN_LABEL = "clean \u2713";
29
+ /** Default copy while the first status read is in flight. */
30
+ export declare const GIT_LOADING_NOTE = "Checking repository status\u2026";
31
+ /** Default copy when no status and no reason were supplied — deliberately says nothing about the
32
+ * tree's actual state. */
33
+ export declare const GIT_UNAVAILABLE_NOTE = "Repository status is unavailable.";
34
+ /** Marker for a RETAINED status shown while the live read is failing: what is on screen is the last
35
+ * received status, not the current one. */
36
+ export declare const GIT_STALE_LABEL = "\u00B7 stale";
37
+ export declare const GIT_STALE_TITLE = "the last read failed \u2014 showing the last received status";
38
+ /** True when a status object was actually supplied. A JS caller can hand `null` where the type says
39
+ * `GitStatus | undefined` — `null` means "no status", so it must take the honest unavailable arm
40
+ * rather than crash on a field read. Both bindings gate on this. */
41
+ export declare function hasGitStatus(status: GitStatus | null | undefined): status is GitStatus;
42
+ /** Branch label: the real name, or the honest "detached HEAD" for `null`. An unreported branch
43
+ * (`undefined`, or any non-string) falls back to the neutral `—` rather than inventing a checkout
44
+ * state. */
45
+ export declare function gitBranchLabel(branch: string | null | undefined): string;
46
+ /** Counter flags in design order (behind · uncommitted · unpushed), collapsing to a single
47
+ * `clean ✓` when the tree is genuinely all-clear.
48
+ *
49
+ * The clean claim is guarded: it is made ONLY when `uncommitted` was reported as 0 AND both
50
+ * upstream counters were reported at all — `null` counts as reported (it is the explicit "no
51
+ * upstream" answer), `undefined` does not. A status that never reported its counters therefore
52
+ * yields NO flags rather than a green "clean ✓" the caller never earned. */
53
+ export declare function gitFlags(status: GitStatus): GitFlag[];
54
+ export interface GitChipState {
55
+ /** No status to show — the chip renders its note instead of flags. */
56
+ unavailable?: boolean;
57
+ /** The shown status is retained, not current. */
58
+ stale?: boolean;
59
+ }
60
+ /** Every class the chip renders, root and elements — the bindings import these rather than
61
+ * spelling the strings themselves, so the two frameworks cannot drift apart on a rename. */
62
+ export declare const GIT_CHIP_PARTS: {
63
+ readonly root: "my-gitchip";
64
+ readonly branch: "my-gitchip__branch";
65
+ readonly flags: "my-gitchip__flags";
66
+ readonly flag: "my-gitchip__flag";
67
+ readonly note: "my-gitchip__note";
68
+ readonly stale: "my-gitchip__stale";
69
+ };
70
+ /** Root class: base + the unavailable/stale modifiers. */
71
+ export declare function gitChipClass(state?: GitChipState): string;
72
+ /** Flag badge class: base + tone modifier (always present — the tone IS the flag's meaning). */
73
+ export declare function gitFlagClass(tone: GitFlagTone): string;
74
+ /** The unavailable arm's sentence: a caller-supplied reason wins; else the loading line while the
75
+ * first read is in flight; else the neutral default. Never describes the tree. */
76
+ export declare function gitChipNote(o?: {
77
+ unavailableNote?: string;
78
+ loading?: boolean;
79
+ }): string;
@@ -0,0 +1,118 @@
1
+ // @mythicalos/ui-core — the git status chip's flag derivation, branch label, copy and class
2
+ // derivation (ds/components-git-chip.html: a bordered inline mono chip — `⎇ <branch>` plus honest
3
+ // counter flags; warn-soft for behind/uncommitted, error-soft for unpushed, ok-soft for a clean
4
+ // tree — "never red unless something is actually wrong").
5
+ //
6
+ // Extracted from the reference implementation in the reference product's session detail pane. Two
7
+ // things were product-shaped there and are lifted out:
8
+ // 1. the `available:false` REASON VOCABULARY (`no_worktree`, `uid_isolation_unsupported`,
9
+ // `session_terminal`, `slugless`, …) and its copy table — those are one daemon's wire enum.
10
+ // The atom keeps the STATE and takes the human sentence as `unavailableNote`, so each product
11
+ // maps its own reason codes to copy and the atom still renders the honest arm.
12
+ // 2. the session view-model envelope (`GitVm`) — replaced by the plain `GitStatus` below.
13
+ //
14
+ // What is DESIGN and therefore stays (the honesty contract — see gitFlags):
15
+ // - `branch: null` is a DETACHED HEAD, rendered as such, never blank,
16
+ // - `behind`/`unpushed` of `null` means NO UPSTREAM: that flag is OMITTED, never rendered "0
17
+ // behind" (a `0` would claim an upstream comparison that never happened),
18
+ // - an absent/unreported status is NEVER coerced into a zero-count "clean ✓" row — the chip
19
+ // renders its unavailable arm (or, for a partially reported status, simply no flags).
20
+ /** The branch glyph the card prefixes the name with. */
21
+ export const GIT_BRANCH_GLYPH = "⎇";
22
+ /** Shown in place of a branch name when there is no status to show at all. */
23
+ export const GIT_BRANCH_UNKNOWN = "—";
24
+ /** A `null` branch is a real, nameable state — never a blank. */
25
+ export const GIT_DETACHED_LABEL = "detached HEAD";
26
+ /** The all-clear flag (token rule #7: the ✓ glyph rides along with the ok-soft color). */
27
+ export const GIT_CLEAN_LABEL = "clean ✓";
28
+ /** Default copy while the first status read is in flight. */
29
+ export const GIT_LOADING_NOTE = "Checking repository status…";
30
+ /** Default copy when no status and no reason were supplied — deliberately says nothing about the
31
+ * tree's actual state. */
32
+ export const GIT_UNAVAILABLE_NOTE = "Repository status is unavailable.";
33
+ /** Marker for a RETAINED status shown while the live read is failing: what is on screen is the last
34
+ * received status, not the current one. */
35
+ export const GIT_STALE_LABEL = "· stale";
36
+ export const GIT_STALE_TITLE = "the last read failed — showing the last received status";
37
+ /** A real, non-negative INTEGER commit/file count. `null` (no upstream), `undefined` (not
38
+ * reported), `NaN`, negatives and fractions are all "not a count" — a fractional value cannot be a
39
+ * number of commits, so it is malformed data and must not be rendered as one ("1.5↓ behind"). */
40
+ function isCount(n) {
41
+ return typeof n === "number" && Number.isInteger(n) && n >= 0;
42
+ }
43
+ /** True when a status object was actually supplied. A JS caller can hand `null` where the type says
44
+ * `GitStatus | undefined` — `null` means "no status", so it must take the honest unavailable arm
45
+ * rather than crash on a field read. Both bindings gate on this. */
46
+ export function hasGitStatus(status) {
47
+ return typeof status === "object" && status !== null;
48
+ }
49
+ /** Branch label: the real name, or the honest "detached HEAD" for `null`. An unreported branch
50
+ * (`undefined`, or any non-string) falls back to the neutral `—` rather than inventing a checkout
51
+ * state. */
52
+ export function gitBranchLabel(branch) {
53
+ if (branch === null)
54
+ return GIT_DETACHED_LABEL;
55
+ if (typeof branch !== "string")
56
+ return GIT_BRANCH_UNKNOWN;
57
+ // A git ref name cannot contain whitespace, so blank-or-whitespace is the same malformed input as
58
+ // `""` — it must not leave the branch slot visibly empty next to a confident status.
59
+ return branch.trim().length > 0 ? branch : GIT_DETACHED_LABEL;
60
+ }
61
+ /** Counter flags in design order (behind · uncommitted · unpushed), collapsing to a single
62
+ * `clean ✓` when the tree is genuinely all-clear.
63
+ *
64
+ * The clean claim is guarded: it is made ONLY when `uncommitted` was reported as 0 AND both
65
+ * upstream counters were reported at all — `null` counts as reported (it is the explicit "no
66
+ * upstream" answer), `undefined` does not. A status that never reported its counters therefore
67
+ * yields NO flags rather than a green "clean ✓" the caller never earned. */
68
+ export function gitFlags(status) {
69
+ const behind = status?.behind;
70
+ const uncommitted = status?.uncommitted;
71
+ const unpushed = status?.unpushed;
72
+ const flags = [];
73
+ if (isCount(behind) && behind > 0)
74
+ flags.push({ label: `${behind}↓ behind`, tone: "warn" });
75
+ if (isCount(uncommitted) && uncommitted > 0) {
76
+ flags.push({ label: `${uncommitted} uncommitted`, tone: "warn" });
77
+ }
78
+ if (isCount(unpushed) && unpushed > 0)
79
+ flags.push({ label: `${unpushed} unpushed`, tone: "error" });
80
+ if (flags.length > 0)
81
+ return flags;
82
+ const reported = (n) => n === null || isCount(n);
83
+ const clean = isCount(uncommitted) && uncommitted === 0 && reported(behind) && reported(unpushed);
84
+ return clean ? [{ label: GIT_CLEAN_LABEL, tone: "ok" }] : [];
85
+ }
86
+ /** Every class the chip renders, root and elements — the bindings import these rather than
87
+ * spelling the strings themselves, so the two frameworks cannot drift apart on a rename. */
88
+ export const GIT_CHIP_PARTS = {
89
+ root: "my-gitchip",
90
+ branch: "my-gitchip__branch",
91
+ flags: "my-gitchip__flags",
92
+ flag: "my-gitchip__flag",
93
+ note: "my-gitchip__note",
94
+ stale: "my-gitchip__stale",
95
+ };
96
+ /** Root class: base + the unavailable/stale modifiers. */
97
+ export function gitChipClass(state = {}) {
98
+ const base = GIT_CHIP_PARTS.root;
99
+ let cls = base;
100
+ if (state.unavailable === true)
101
+ cls += ` ${base}--unavailable`;
102
+ if (state.stale === true)
103
+ cls += ` ${base}--stale`;
104
+ return cls;
105
+ }
106
+ /** Flag badge class: base + tone modifier (always present — the tone IS the flag's meaning). */
107
+ export function gitFlagClass(tone) {
108
+ const base = GIT_CHIP_PARTS.flag;
109
+ return `${base} ${base}--${tone}`;
110
+ }
111
+ /** The unavailable arm's sentence: a caller-supplied reason wins; else the loading line while the
112
+ * first read is in flight; else the neutral default. Never describes the tree. */
113
+ export function gitChipNote(o = {}) {
114
+ const note = o.unavailableNote;
115
+ if (typeof note === "string" && note.trim().length > 0)
116
+ return note;
117
+ return o.loading === true ? GIT_LOADING_NOTE : GIT_UNAVAILABLE_NOTE;
118
+ }
@@ -0,0 +1,39 @@
1
+ /** The separator between the bold "N unsaved change(s)" count and the changed-field list. */
2
+ export declare const SAVE_BAR_SEP = " \u00B7 ";
3
+ /** Design-card copy for the two actions (ds/layouts-settings.html). Both are overridable per
4
+ * surface — the reference product's settings page says "Save locally" because its save writes to
5
+ * the local container, not the card's "Save & apply". */
6
+ export declare const SAVE_BAR_DISCARD_LABEL = "Discard";
7
+ export declare const SAVE_BAR_SAVE_LABEL = "Save & apply";
8
+ export interface SaveBarNote {
9
+ /** How many fields are dirty. `0` ⇒ the bar must not be shown at all (see `saveBarDirty`). */
10
+ count: number;
11
+ /** The bold lead-in: `1 unsaved change` / `3 unsaved changes`. */
12
+ countLabel: string;
13
+ /** The changed-field list that follows the separator: `Base URL, Harness`. */
14
+ detail: string;
15
+ /** The full visible sentence, `countLabel + SAVE_BAR_SEP + detail`. Empty `detail` ⇒ just the
16
+ * count (never a dangling separator). */
17
+ text: string;
18
+ }
19
+ /** Compose the save bar's note from the list of changed-field labels. Pluralization is decided
20
+ * here, once, for both bindings. Non-string / blank entries are dropped rather than rendered as
21
+ * `undefined` in the list — but they are dropped BEFORE the count, so the count always equals the
22
+ * number of fields actually named. */
23
+ export declare function saveBarNote(changed: readonly string[]): SaveBarNote;
24
+ /** The card's "the save bar slides up only when dirty" rule, as a predicate. The bindings return
25
+ * `null` for a not-dirty bar so no surface can ever render "0 unsaved changes" next to a live
26
+ * Save button. */
27
+ export declare function saveBarDirty(changed: readonly string[]): boolean;
28
+ /** Every class the bar renders, root and elements. The bindings import these rather than spelling
29
+ * the strings themselves, so a rename cannot land in one framework and not the other, and the
30
+ * stylesheet test can enumerate the parts instead of restating a hand-written list. */
31
+ export declare const SAVE_BAR_PARTS: {
32
+ readonly root: "my-savebar";
33
+ readonly note: "my-savebar__note";
34
+ readonly count: "my-savebar__count";
35
+ readonly actions: "my-savebar__actions";
36
+ };
37
+ /** Root class for the bar. No tone axis — the modifier space is reserved for the caller's own
38
+ * passthrough class, which the bindings append. */
39
+ export declare function saveBarClass(): string;
@@ -0,0 +1,54 @@
1
+ // @mythicalos/ui-core — the dirty-state save bar's pure text composition + class derivation
2
+ // (ds/layouts-settings.html: the bar pinned to the bottom of the settings screen —
3
+ // `1 unsaved change · Base URL` on the left, Discard + the page's single primary on the right).
4
+ //
5
+ // Extracted from the reference implementation in the reference product's settings/projects pages,
6
+ // which took a `(keyof MythicalConfig)[]` — a product config's key union. That product coupling is
7
+ // lifted out here: the atom takes plain `string[]` field labels and composes the same sentence.
8
+ //
9
+ // The count/plural derivation and the separator live here (not in the bindings) so the Preact and
10
+ // React renders can never drift, and so a consumer can assert the exact visible sentence without a
11
+ // DOM — the same contract `composeToastText` holds for the toast.
12
+ /** The separator between the bold "N unsaved change(s)" count and the changed-field list. */
13
+ export const SAVE_BAR_SEP = " · ";
14
+ /** Design-card copy for the two actions (ds/layouts-settings.html). Both are overridable per
15
+ * surface — the reference product's settings page says "Save locally" because its save writes to
16
+ * the local container, not the card's "Save & apply". */
17
+ export const SAVE_BAR_DISCARD_LABEL = "Discard";
18
+ export const SAVE_BAR_SAVE_LABEL = "Save & apply";
19
+ /** Compose the save bar's note from the list of changed-field labels. Pluralization is decided
20
+ * here, once, for both bindings. Non-string / blank entries are dropped rather than rendered as
21
+ * `undefined` in the list — but they are dropped BEFORE the count, so the count always equals the
22
+ * number of fields actually named. */
23
+ export function saveBarNote(changed) {
24
+ const fields = (changed ?? []).filter((f) => typeof f === "string" && f.trim().length > 0);
25
+ const count = fields.length;
26
+ const countLabel = `${count} unsaved ${count === 1 ? "change" : "changes"}`;
27
+ const detail = fields.join(", ");
28
+ return {
29
+ count,
30
+ countLabel,
31
+ detail,
32
+ text: detail.length > 0 ? `${countLabel}${SAVE_BAR_SEP}${detail}` : countLabel,
33
+ };
34
+ }
35
+ /** The card's "the save bar slides up only when dirty" rule, as a predicate. The bindings return
36
+ * `null` for a not-dirty bar so no surface can ever render "0 unsaved changes" next to a live
37
+ * Save button. */
38
+ export function saveBarDirty(changed) {
39
+ return saveBarNote(changed).count > 0;
40
+ }
41
+ /** Every class the bar renders, root and elements. The bindings import these rather than spelling
42
+ * the strings themselves, so a rename cannot land in one framework and not the other, and the
43
+ * stylesheet test can enumerate the parts instead of restating a hand-written list. */
44
+ export const SAVE_BAR_PARTS = {
45
+ root: "my-savebar",
46
+ note: "my-savebar__note",
47
+ count: "my-savebar__count",
48
+ actions: "my-savebar__actions",
49
+ };
50
+ /** Root class for the bar. No tone axis — the modifier space is reserved for the caller's own
51
+ * passthrough class, which the bindings append. */
52
+ export function saveBarClass() {
53
+ return SAVE_BAR_PARTS.root;
54
+ }
@@ -0,0 +1,49 @@
1
+ /** Tile tone. `accent` is the design card's "brag" number (`.tile.save .v`); `warn`/`error` are the
2
+ * gauge-band tint. Absent ⇒ a plain tile. */
3
+ export type StatTileTone = "accent" | "warn" | "error";
4
+ export interface StatTile {
5
+ /** Uppercase micro key, e.g. `tokens in`. */
6
+ label: string;
7
+ /** The already-formatted value — use the formatters below, or supply your own string. */
8
+ value: string;
9
+ /** Mono sub-caption under the value, e.g. `this session`. */
10
+ sub?: string;
11
+ tone?: StatTileTone;
12
+ }
13
+ /** The value shown when a number is absent or not finite. An em dash — NEVER a zero. */
14
+ export declare const STAT_TILE_EMPTY = "\u2014";
15
+ /** Unicode minus (U+2212), not an ASCII hyphen: it matches the digit metrics of the tabular-nums
16
+ * mono face the tile value is set in. */
17
+ export declare const STAT_TILE_MINUS = "\u2212";
18
+ /** Every class the row renders, containers and elements — the bindings import these rather than
19
+ * spelling the strings themselves, so the two frameworks cannot drift apart on a rename. */
20
+ export declare const STAT_TILE_PARTS: {
21
+ readonly row: "my-stat-tiles";
22
+ readonly tile: "my-stat-tile";
23
+ readonly label: "my-stat-tile__label";
24
+ readonly value: "my-stat-tile__value";
25
+ readonly sub: "my-stat-tile__sub";
26
+ };
27
+ /** Row container class. */
28
+ export declare function statTilesClass(): string;
29
+ /** Tile class: base + optional tone modifier. */
30
+ export declare function statTileClass(tone?: StatTileTone): string;
31
+ /** Compact count: `500`, `12.8k`, `230k`, `1.5M`; negatives take the Unicode minus (`−212k`).
32
+ * Absent/non-finite ⇒ `STAT_TILE_EMPTY`.
33
+ *
34
+ * The unit is chosen from the ROUNDED value, not the raw one: rounding first and picking the unit
35
+ * second would render `999.5` as `1000` and `999_999` as `1000k` — values that are noncanonical
36
+ * for the scale and sit right in the middle of a normal token count. Each rung promotes to the
37
+ * next when its own rounding carries it to 1000. `M` is the top rung — the design card defines no
38
+ * unit above it, so a billion reads `1000M`, not `1B`. */
39
+ export declare function formatStatCompact(n: number | null | undefined): string;
40
+ /** Grouped count: `1,204,551` — the design card's tokens-in/out format. Deliberately NOT
41
+ * `toLocaleString()`: the card's grouping is a fixed visual (comma every three digits, mono
42
+ * tabular-nums), and a host locale must not silently turn it into `1.204.551`.
43
+ * Absent/non-finite ⇒ `STAT_TILE_EMPTY`. */
44
+ export declare function formatStatCount(n: number | null | undefined): string;
45
+ /** USD: `$4.18`, always two decimals. Absent/non-finite ⇒ `STAT_TILE_EMPTY`. */
46
+ export declare function formatStatUsd(n: number | null | undefined): string;
47
+ /** Percent: `71%` by default, `94.5%` at `digits: 1` — the design card shows both forms.
48
+ * Absent/non-finite ⇒ `STAT_TILE_EMPTY`. */
49
+ export declare function formatStatPercent(n: number | null | undefined, digits?: number): string;
@@ -0,0 +1,102 @@
1
+ // @mythicalos/ui-core — the stat-tile row's tone→class derivation + the value formatters
2
+ // (ds/components-stat-tiles.html: a flex-wrap row of bordered tiles, each an uppercase micro
3
+ // "key", a big mono tabular-nums value, and a mono sub-caption; the "brag" tile paints its value
4
+ // --my-accent-strong).
5
+ //
6
+ // Extracted from the reference implementation in the reference product's session detail pane. That
7
+ // version took the product's `SessionVm` and hard-coded the five session tiles (tokens in/out,
8
+ // prompt cache, cost, spine savings) INSIDE the component. That composition is the product's
9
+ // domain, not the design system's: the atom here takes an already-composed `StatTile[]`, so a
10
+ // consumer's view-model decides which tiles exist and in what order.
11
+ //
12
+ // What DID come along, because it is design and not product:
13
+ // - the "—" placeholder: an absent value renders the em dash, never a fabricated `0` / `$0.00`
14
+ // (partial data renders partially — never all-or-nothing),
15
+ // - the Unicode minus (U+2212, not a hyphen) on negative compact counts — the design card's
16
+ // spine-savings tile literally reads `−212k`,
17
+ // - the warn/error tone tint, which the reference drove off the context gauge's own 75/90 bands
18
+ // (token rule #4) — the thresholds themselves stay with the caller, only the tint is here.
19
+ /** The value shown when a number is absent or not finite. An em dash — NEVER a zero. */
20
+ export const STAT_TILE_EMPTY = "—";
21
+ /** Unicode minus (U+2212), not an ASCII hyphen: it matches the digit metrics of the tabular-nums
22
+ * mono face the tile value is set in. */
23
+ export const STAT_TILE_MINUS = "−";
24
+ /** Every class the row renders, containers and elements — the bindings import these rather than
25
+ * spelling the strings themselves, so the two frameworks cannot drift apart on a rename. */
26
+ export const STAT_TILE_PARTS = {
27
+ row: "my-stat-tiles",
28
+ tile: "my-stat-tile",
29
+ label: "my-stat-tile__label",
30
+ value: "my-stat-tile__value",
31
+ sub: "my-stat-tile__sub",
32
+ };
33
+ /** Row container class. */
34
+ export function statTilesClass() {
35
+ return STAT_TILE_PARTS.row;
36
+ }
37
+ /** Tile class: base + optional tone modifier. */
38
+ export function statTileClass(tone) {
39
+ const base = STAT_TILE_PARTS.tile;
40
+ return tone === undefined ? base : `${base} ${base}--${tone}`;
41
+ }
42
+ /** True only for a real, finite number — `undefined`, `null` and `NaN`/`Infinity` all fall through
43
+ * to `STAT_TILE_EMPTY` rather than being coerced. */
44
+ function isNum(n) {
45
+ return typeof n === "number" && Number.isFinite(n);
46
+ }
47
+ /** Strip a trailing `.0` so `230.0` → `230` while `12.8` stays `12.8`. */
48
+ function stripZero(s) {
49
+ return s.endsWith(".0") ? s.slice(0, -2) : s;
50
+ }
51
+ /** Sign prefix for an already-rounded magnitude string. A negative input whose ROUNDED magnitude is
52
+ * zero gets no sign — `−0` / `−$0.00` would be a lie about the direction of a value that landed on
53
+ * zero at display precision. */
54
+ function signed(n, body, zero) {
55
+ return n < 0 && body !== zero ? STAT_TILE_MINUS + body : body;
56
+ }
57
+ /** Compact count: `500`, `12.8k`, `230k`, `1.5M`; negatives take the Unicode minus (`−212k`).
58
+ * Absent/non-finite ⇒ `STAT_TILE_EMPTY`.
59
+ *
60
+ * The unit is chosen from the ROUNDED value, not the raw one: rounding first and picking the unit
61
+ * second would render `999.5` as `1000` and `999_999` as `1000k` — values that are noncanonical
62
+ * for the scale and sit right in the middle of a normal token count. Each rung promotes to the
63
+ * next when its own rounding carries it to 1000. `M` is the top rung — the design card defines no
64
+ * unit above it, so a billion reads `1000M`, not `1B`. */
65
+ export function formatStatCompact(n) {
66
+ if (!isNum(n))
67
+ return STAT_TILE_EMPTY;
68
+ const abs = Math.abs(n);
69
+ let body;
70
+ if (Math.round(abs) < 1000)
71
+ body = String(Math.round(abs));
72
+ else if (Number((abs / 1000).toFixed(1)) < 1000)
73
+ body = `${stripZero((abs / 1000).toFixed(1))}k`;
74
+ else
75
+ body = `${stripZero((abs / 1_000_000).toFixed(1))}M`;
76
+ return signed(n, body, "0");
77
+ }
78
+ /** Grouped count: `1,204,551` — the design card's tokens-in/out format. Deliberately NOT
79
+ * `toLocaleString()`: the card's grouping is a fixed visual (comma every three digits, mono
80
+ * tabular-nums), and a host locale must not silently turn it into `1.204.551`.
81
+ * Absent/non-finite ⇒ `STAT_TILE_EMPTY`. */
82
+ export function formatStatCount(n) {
83
+ if (!isNum(n))
84
+ return STAT_TILE_EMPTY;
85
+ const grouped = String(Math.round(Math.abs(n))).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
86
+ return signed(n, grouped, "0");
87
+ }
88
+ /** USD: `$4.18`, always two decimals. Absent/non-finite ⇒ `STAT_TILE_EMPTY`. */
89
+ export function formatStatUsd(n) {
90
+ if (!isNum(n))
91
+ return STAT_TILE_EMPTY;
92
+ return signed(n, `$${Math.abs(n).toFixed(2)}`, "$0.00");
93
+ }
94
+ /** Percent: `71%` by default, `94.5%` at `digits: 1` — the design card shows both forms.
95
+ * Absent/non-finite ⇒ `STAT_TILE_EMPTY`. */
96
+ export function formatStatPercent(n, digits = 0) {
97
+ if (!isNum(n))
98
+ return STAT_TILE_EMPTY;
99
+ const safeDigits = Math.max(0, Math.min(4, Math.trunc(digits)));
100
+ const body = `${Math.abs(n).toFixed(safeDigits)}%`;
101
+ return signed(n, body, `${(0).toFixed(safeDigits)}%`);
102
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mythicalos/ui-core",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "mythicalOS framework-agnostic UI core — the pure logic (class derivation, poll scheduling, dialog/toast/gauge helpers), the token-driven component stylesheet, and the <mythical-select> form-associated web component. Zero framework runtime; the Preact and React bindings derive identical output from this package. Apache-2.0.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -224,7 +224,11 @@ button:focus-visible{outline:2px solid var(--my-accent,#0F6B66);outline-offset:2
224
224
  #label:empty::before{content:"\\00a0"}
225
225
  .car{flex:none;color:var(--my-muted,#5A5F6A);transition:transform var(--my-t-fast,120ms ease)}
226
226
  :host([data-open]) .car{transform:rotate(180deg)}
227
- #pop{position:absolute;top:calc(100% + 6px);left:0;z-index:30;min-width:100%;max-height:280px;
227
+ /* Pinned to BOTH edges with border-box sizing: under the old
228
+ `min-width:100%` + content-box, the 6px padding and 1px border were added
229
+ OUTSIDE the 100%, so the popup rendered 14px wider than its own control. */
230
+ #pop{position:absolute;top:calc(100% + 6px);left:0;right:0;z-index:30;width:100%;
231
+ box-sizing:border-box;max-height:280px;
228
232
  overflow:auto;overscroll-behavior:contain;padding:6px;background:var(--my-surface,#fff);
229
233
  border:1px solid var(--my-border,#E7E3DB);border-radius:var(--my-r-card,10px);
230
234
  box-shadow:var(--my-shadow-modal,0 16px 48px rgba(22,24,29,.16));
package/styles.css CHANGED
@@ -71,6 +71,11 @@ a:focus-visible, .link:focus-visible, .is-focus {
71
71
  transition: border-color var(--my-t-fast);
72
72
  }
73
73
  .input:hover:not(:disabled) { border-color: var(--my-muted); } /* §1a-4 (input row v2 reference) */
74
+ /* `.mono` is declared far earlier at the SAME specificity (0,1,0), so `.input`'s `font: inherit`
75
+ shorthand above wins the cascade and resets the family — a `mono` input silently rendered in the
76
+ body face. Restate it at (0,2,0) so the modifier survives inside this package alone, rather than
77
+ depending on a consumer happening to re-declare `.mono` after our sheet. */
78
+ .input.mono { font-family: var(--my-font-mono); font-size: var(--my-fs-mono); }
74
79
  .input.is-err { border-color: var(--my-error); }
75
80
  .input:disabled, .input.is-disabled { background: var(--my-disabled-bg); color: var(--my-disabled-ink); cursor: not-allowed; }
76
81
  .input.is-dirty { border-color: var(--my-accent); }
@@ -322,3 +327,101 @@ a:focus-visible, .link:focus-visible, .is-focus {
322
327
  /* design rule 6 — the shared focus ring, restated rather than added to the BASE selector list
323
328
  above for the same reason the rules above live here */
324
329
  .input-reveal__btn:focus-visible { outline: 2px solid var(--my-accent); outline-offset: 2px; }
330
+
331
+ /* ══════════════════════════════════════════════════════════════════════════════════════════
332
+ SMALL ATOMS — save bar · stat tiles · git status chip. Each was registered in the design
333
+ system but implemented only inside one product; these rules are taken from the cards
334
+ (ds/layouts-settings.html, ds/components-stat-tiles.html, ds/components-git-chip.html) with
335
+ colors/font-sizes remapped onto canonical --my-* tokens. Where the product's local sheet and
336
+ the card differed, the CARD wins and the delta is noted inline.
337
+ ══════════════════════════════════════════════════════════════════════════════════════════ */
338
+
339
+ /* save bar (ds/layouts-settings.html `.save`) — full-bleed bottom, slides up only when dirty.
340
+ The card draws it `position:absolute` inside its own bordered screen frame; a packaged atom has
341
+ no such frame to anchor to, so it is `fixed` to the viewport (what the reference product's
342
+ sheet does too) — a consumer that needs it scoped to a pane overrides `position` via the
343
+ passthrough class. Padding is the card's `9px 16px`; the product's own sheet insets it to 24px
344
+ horizontally, which a consumer can restore from its own sheet. */
345
+ .my-savebar {
346
+ position: fixed; left: 0; right: 0; bottom: 0; z-index: 30;
347
+ background: var(--my-surface); border-top: 1px solid var(--my-border);
348
+ display: flex; align-items: center; gap: 10px;
349
+ padding: 9px var(--my-sp-4); font-size: var(--my-fs-caption); /* the card's 9px 16px */
350
+ animation: my-savebar-up 180ms ease-out; /* the card's "slides up (180ms)" */
351
+ }
352
+ @keyframes my-savebar-up { from { transform: translateY(100%); } }
353
+ @media (prefers-reduced-motion: reduce) { .my-savebar { animation: none; } }
354
+ .my-savebar__note { color: var(--my-muted); }
355
+ .my-savebar__count { color: var(--my-ink); font-weight: var(--my-fw-bold); }
356
+ .my-savebar__actions { margin-left: auto; display: flex; gap: var(--my-sp-2); }
357
+
358
+ /* stat tiles (ds/components-stat-tiles.html `.tiles`/`.tile`) — mono, tabular-nums, micro key +
359
+ mono sub-caption. The card sizes tiles by content off a 112px floor; the reference product's
360
+ sheet additionally stretches them with `flex: 1`, which a consumer can restore from its own
361
+ sheet (`.my-stat-tiles > .my-stat-tile { flex: 1 }`). */
362
+ .my-stat-tiles { display: flex; gap: 10px; flex-wrap: wrap; }
363
+ .my-stat-tile {
364
+ min-width: 112px; background: var(--my-surface);
365
+ border: 1px solid var(--my-border); border-radius: var(--my-r-card); padding: 10px 12px;
366
+ }
367
+ .my-stat-tile__label {
368
+ font-size: var(--my-fs-micro); /* card: 10px, no exact token step */
369
+ font-weight: var(--my-fw-bold); text-transform: uppercase; letter-spacing: .4px;
370
+ color: var(--my-muted);
371
+ }
372
+ .my-stat-tile__value {
373
+ font-family: var(--my-font-mono);
374
+ font-size: var(--my-fs-body-lg); /* card: 17px, nearest token step */
375
+ font-weight: var(--my-fw-bold); font-variant-numeric: tabular-nums; margin-top: 2px;
376
+ }
377
+ .my-stat-tile__sub {
378
+ font-family: var(--my-font-mono);
379
+ font-size: var(--my-fs-micro); /* card: 10.5px, no exact token step */
380
+ color: var(--my-muted);
381
+ }
382
+ /* the card's "brag" number (`.tile.save .v` → --my-accent-strong) */
383
+ .my-stat-tile--accent .my-stat-tile__value { color: var(--my-accent-strong); }
384
+ /* band tint (token rule #4 thresholds live with the caller, only the tint is here) */
385
+ .my-stat-tile--warn { border-color: var(--my-warn); }
386
+ .my-stat-tile--warn .my-stat-tile__value { color: var(--my-warn); }
387
+ .my-stat-tile--error { border-color: var(--my-error); }
388
+ .my-stat-tile--error .my-stat-tile__value { color: var(--my-error); }
389
+
390
+ /* git status chip (ds/components-git-chip.html `.gitchip`/`.gitflag`) — a bordered inline mono
391
+ chip. The flags are the card's own squared 4px badge, NOT the pill `.my-chip`: inside a mono
392
+ chip the card gives them a tighter, squared shape, and design rule #10 reserves the pill for
393
+ stand-alone non-interactive tags.
394
+
395
+ Three deliberate deltas from the card, all recorded here (and pinned by
396
+ test/css-small-atoms.test.ts) rather than left to be discovered:
397
+ - `flex-wrap: wrap` on the chip and its flag group. The card demonstrates the chip inside a
398
+ fixed 820px panel where it cannot run out of room; a packaged atom renders at whatever width
399
+ the consumer gives it, and a long branch name plus three flags must wrap rather than
400
+ overflow its container.
401
+ - the branch keeps the card's inherited weight (NOT bold). The product's local sheet bolds it;
402
+ the card does not, so the card wins.
403
+ - the flag weight is `--my-fw-bold` (650), where the card's literal is 700. There is no 700
404
+ step on the canonical --my-fw-* scale, and the additions section above already normalizes
405
+ badge weights onto the nearest token (`.my-chip` does exactly this). */
406
+ .my-gitchip {
407
+ display: inline-flex; gap: 10px; align-items: center; flex-wrap: wrap; /* delta 1 */
408
+ font-family: var(--my-font-mono); font-size: var(--my-fs-caption);
409
+ border: 1px solid var(--my-border); border-radius: var(--my-r-control);
410
+ padding: 6px 10px; background: var(--my-surface);
411
+ }
412
+ .my-gitchip__branch { color: var(--my-ink); } /* delta 2 — card weight, not the product's bold */
413
+ .my-gitchip__flags { display: inline-flex; gap: 6px; flex-wrap: wrap; align-items: center; }
414
+ .my-gitchip__flag {
415
+ font-size: var(--my-fs-micro); /* card: 10px, no exact token step */
416
+ font-weight: var(--my-fw-bold); /* delta 3 — card: 700, no exact token step */
417
+ letter-spacing: .3px; border-radius: 4px; padding: 2px 6px;
418
+ }
419
+ .my-gitchip__flag--warn { background: var(--my-warn-soft); color: var(--my-warn); }
420
+ .my-gitchip__flag--error { background: var(--my-error-soft); color: var(--my-error); }
421
+ .my-gitchip__flag--ok { background: var(--my-ok-soft); color: var(--my-ok); }
422
+ /* the honest unavailable arm: the branch slot is muted and the note carries the reason in UI type */
423
+ .my-gitchip--unavailable .my-gitchip__branch { color: var(--my-muted); }
424
+ .my-gitchip__note { font-family: var(--my-font-ui); color: var(--my-muted); }
425
+ /* a retained (not current) status — flagged on BOTH arms */
426
+ .my-gitchip--stale { border-color: var(--my-warn); }
427
+ .my-gitchip__stale { color: var(--my-warn); font-size: var(--my-fs-micro); }