@mythicalos/ui-core 0.2.1 → 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 +3 -0
- package/dist/index.js +4 -0
- package/dist/logic/git-chip.d.ts +79 -0
- package/dist/logic/git-chip.js +118 -0
- package/dist/logic/save-bar.d.ts +39 -0
- package/dist/logic/save-bar.js +54 -0
- package/dist/logic/stat-tiles.d.ts +49 -0
- package/dist/logic/stat-tiles.js +102 -0
- package/package.json +1 -1
- package/src/select/mythical-select.js +5 -1
- package/styles.css +98 -0
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.
|
|
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
|
-
|
|
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
|
@@ -327,3 +327,101 @@ a:focus-visible, .link:focus-visible, .is-focus {
|
|
|
327
327
|
/* design rule 6 — the shared focus ring, restated rather than added to the BASE selector list
|
|
328
328
|
above for the same reason the rules above live here */
|
|
329
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); }
|