sid-ui 0.1.1 → 0.1.3

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/README.md CHANGED
@@ -37,6 +37,18 @@ Next.js apps add `sid-ui` to `transpilePackages`, because the package ships Type
37
37
  | `sid-ui/places` | Place types and helpers |
38
38
  | `sid-ui/geoapify` | Server helper for place search. Pass `process.env.GEOAPIFY_API_KEY`. |
39
39
  | `sid-ui/brand-name` | Product name constant, without the component barrel |
40
+ | `sid-ui/browser-theme` | `theme-color` values for the browser chrome, for server layouts |
41
+
42
+ ## Catalog
43
+
44
+ The component catalog is the `sid-ui-theme` app in `theme/`. It is a static site on GitHub Pages: https://sid0709.github.io/sid-ui/
45
+
46
+ ```bash
47
+ bun run dev:theme
48
+ bun run build:theme
49
+ ```
50
+
51
+ Address autocomplete stays on the product apps. GitHub Pages cannot keep a Geoapify key on a server.
40
52
 
41
53
  ## Theme source
42
54
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sid-ui",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "packageManager": "bun@1.4.2",
5
5
  "description": "React components, tokens, and theme used by Joined products.",
6
6
  "repository": {
@@ -13,6 +13,7 @@
13
13
  "homepage": "https://github.com/sid0709/sid-ui#readme",
14
14
  "keywords": ["react", "design-system", "components", "ui"],
15
15
  "sideEffects": ["*.css"],
16
+ "workspaces": ["theme"],
16
17
  "files": ["src", "!src/**/*.test.ts", "styles", "styles.css", "README.md"],
17
18
  "types": "./src/components/index.ts",
18
19
  "main": "./src/components/index.ts",
@@ -21,6 +22,7 @@
21
22
  "./places": "./src/components/places.ts",
22
23
  "./geoapify": "./src/places/geoapify.ts",
23
24
  "./brand-name": "./src/brand/name.ts",
25
+ "./browser-theme": "./src/brand/chrome.ts",
24
26
  "./styles.css": "./styles.css",
25
27
  "./styles/astryx.css": "./styles/astryx.css",
26
28
  "./styles/components.css": "./styles/components.css",
@@ -37,6 +39,8 @@
37
39
  "typecheck": "tsc --noEmit --incremental false -p tsconfig.json",
38
40
  "test": "bun test",
39
41
  "theme:build": "astryx theme build src/theme/joined.theme.ts -o src/theme/theme.css",
42
+ "dev:theme": "bun --filter sid-ui-theme dev",
43
+ "build:theme": "bun --filter sid-ui-theme build",
40
44
  "prepublishOnly": "bun run typecheck && bun test"
41
45
  },
42
46
  "dependencies": {
@@ -1,3 +1,5 @@
1
+ import type { ReactNode } from "react";
2
+
1
3
  import { JoinedLogo } from "./JoinedLogo";
2
4
  import { JoinedMark } from "./JoinedMark";
3
5
 
@@ -11,14 +13,20 @@ export interface BrandFooterProps {
11
13
  href?: string;
12
14
  align?: "center" | "start";
13
15
  className?: string;
16
+ /** More footer content on its own line under the sign-off, such as legal links. */
17
+ children?: ReactNode;
14
18
  }
15
19
 
16
- /** A quiet sign-off under landing, auth, and error pages: the symbol, a lead, and the wordmark. */
20
+ /**
21
+ * A quiet sign-off under landing, auth, and error pages: the symbol, a lead, and the
22
+ * wordmark. It is the page's footer landmark, so put the legal links inside it.
23
+ */
17
24
  export function BrandFooter({
18
25
  lead = "Part of",
19
26
  href,
20
27
  align = "center",
21
28
  className,
29
+ children,
22
30
  }: BrandFooterProps) {
23
31
  const classes = ["os-brand-footer", align === "start" && "os-brand-footer-start", className]
24
32
  .filter(Boolean)
@@ -40,6 +48,7 @@ export function BrandFooter({
40
48
  ) : (
41
49
  <span className="os-brand-footer-link">{signOff}</span>
42
50
  )}
51
+ {children && <div className="os-brand-footer-extra">{children}</div>}
43
52
  </footer>
44
53
  );
45
54
  }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The browser's own chrome color (`<meta name="theme-color">`), per color scheme.
3
+ * It matches `--color-background-surface`, the top bar every app shell paints,
4
+ * so the mobile address bar runs straight into the header. A meta tag cannot
5
+ * read a CSS variable, so the values live here; a test keeps them equal to the theme.
6
+ * Also exported alone as `sid-ui/browser-theme` for server layouts.
7
+ */
8
+ export const BROWSER_THEME_COLOR = {
9
+ light: "#fbfcff",
10
+ dark: "#1a1b22",
11
+ } as const;
@@ -1,4 +1,5 @@
1
1
  export { BRAND_NAME } from "./name";
2
+ export { BROWSER_THEME_COLOR } from "./chrome";
2
3
  export { JoinedLogo } from "./JoinedLogo";
3
4
  export type { JoinedLogoProps, JoinedLogoVariant } from "./JoinedLogo";
4
5
  export { JoinedMark } from "./JoinedMark";
@@ -35,13 +35,13 @@ export function SegmentBar({
35
35
  )
36
36
  .join(", ");
37
37
  return (
38
- <div className="os-segments">
39
- <div className="os-segment-track" role="img" aria-label={summary}>
38
+ <div className="os-segment-bar">
39
+ <div className="os-segment-bar-track" role="img" aria-label={summary}>
40
40
  {segments.map((segment, index) =>
41
41
  shares[index] ? (
42
42
  <span
43
43
  key={segment.label}
44
- className="os-segment"
44
+ className="os-segment-bar-fill"
45
45
  data-tone={segment.tone ?? "blue"}
46
46
  style={{ width: `${shares[index]}%` }}
47
47
  />
@@ -49,10 +49,10 @@ export function SegmentBar({
49
49
  )}
50
50
  </div>
51
51
  {hasLegend ? (
52
- <ul className="os-segment-legend">
52
+ <ul className="os-segment-bar-legend">
53
53
  {segments.map((segment) => (
54
54
  <li key={segment.label}>
55
- <span className="os-segment-key" data-tone={segment.tone ?? "blue"} />
55
+ <span className="os-segment-bar-key" data-tone={segment.tone ?? "blue"} />
56
56
  <span>{segment.label}</span>
57
57
  <strong>{segment.display ?? segment.value}</strong>
58
58
  </li>
@@ -0,0 +1,44 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export interface ConsentBannerProps {
4
+ /** Short heading, e.g. "Cookies on Joined". Also names the region for assistive tech. */
5
+ title: string;
6
+ /** One or two sentences on what is being asked. */
7
+ description: ReactNode;
8
+ /** The choices, most permissive last — usually a secondary and a primary Button. */
9
+ actions: ReactNode;
10
+ /** A quiet link to the full policy, shown under the description. */
11
+ link?: ReactNode;
12
+ /**
13
+ * `fixed` floats above the page at the bottom of the viewport. `inline` sits in
14
+ * the flow, for previews and settings pages that repeat the question.
15
+ */
16
+ placement?: "fixed" | "inline";
17
+ }
18
+
19
+ /**
20
+ * A request for consent that waits for an answer: cookies, tracking, a new terms
21
+ * version. It floats at the bottom of the viewport, a card-width panel on wide
22
+ * screens and an edge-to-edge sheet on phones, and never covers the whole page.
23
+ */
24
+ export function ConsentBanner({
25
+ title,
26
+ description,
27
+ actions,
28
+ link,
29
+ placement = "fixed",
30
+ }: ConsentBannerProps) {
31
+ return (
32
+ <section
33
+ className={placement === "fixed" ? "os-consent os-consent-fixed" : "os-consent"}
34
+ aria-label={title}
35
+ >
36
+ <div className="os-consent-body">
37
+ <h2 className="os-consent-title">{title}</h2>
38
+ <p className="os-consent-description">{description}</p>
39
+ {link && <div className="os-consent-link">{link}</div>}
40
+ </div>
41
+ <div className="os-consent-actions">{actions}</div>
42
+ </section>
43
+ );
44
+ }
@@ -385,7 +385,7 @@ export function KanbanBoard<T extends KanbanItemBase>({
385
385
  );
386
386
 
387
387
  return (
388
- <ScrollableArea label={label} axis="inline">
388
+ <ScrollableArea label={label} axis="inline" className="os-kanban-scroller">
389
389
  <VisuallyHidden>
390
390
  <span id={hintId}>{KEY_HINT}</span>
391
391
  <span aria-live="polite">{announcement}</span>
@@ -0,0 +1,96 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+
5
+ /** How a day went: fine, degraded, down, or nothing measured yet. */
6
+ export type UptimeTone = "up" | "degraded" | "down" | "none";
7
+
8
+ export type UptimeDay = {
9
+ /** Shown as the tip's title, e.g. "Oct 11, 2026". */
10
+ label: string;
11
+ tone: UptimeTone;
12
+ /** One line per fact in the tip, e.g. "99.98% uptime", "Slow search". */
13
+ details?: string[];
14
+ };
15
+
16
+ const TONE_LABEL: Record<UptimeTone, string> = {
17
+ up: "No downtime",
18
+ degraded: "Degraded",
19
+ down: "Outage",
20
+ none: "No data",
21
+ };
22
+
23
+ /** Keeps the tip inside the bar near either end. */
24
+ const EDGE_SHARE = 0.15;
25
+
26
+ /**
27
+ * A status-page history: one slim bar per day, oldest on the left, coloured by how the
28
+ * day went. Each bar reads out its date and details on hover or focus.
29
+ */
30
+ export function UptimeBar({
31
+ days,
32
+ label,
33
+ startLabel,
34
+ endLabel,
35
+ summary,
36
+ }: {
37
+ days: UptimeDay[];
38
+ /** Accessible name for the row of days, e.g. "API uptime, last 90 days". */
39
+ label: string;
40
+ /** Under the left end, e.g. "90 days ago". */
41
+ startLabel?: string;
42
+ /** Under the right end, e.g. "Today". */
43
+ endLabel?: string;
44
+ /** Centered under the bars, e.g. "99.98% uptime". */
45
+ summary?: string;
46
+ }) {
47
+ const [active, setActive] = useState<number | null>(null);
48
+ const align = (index: number) => {
49
+ const share = days.length > 1 ? index / (days.length - 1) : 0.5;
50
+ if (share < EDGE_SHARE) return "start";
51
+ if (share > 1 - EDGE_SHARE) return "end";
52
+ return "center";
53
+ };
54
+
55
+ return (
56
+ <div className="os-uptime">
57
+ <div className="os-uptime-days" role="group" aria-label={label}>
58
+ {days.map((day, index) => {
59
+ const details = day.details?.length ? day.details : [TONE_LABEL[day.tone]];
60
+ return (
61
+ <span
62
+ key={`${day.label}-${index}`}
63
+ className="os-uptime-day"
64
+ data-tone={day.tone}
65
+ tabIndex={0}
66
+ role="img"
67
+ aria-label={`${day.label}: ${details.join(", ")}`}
68
+ onPointerEnter={() => setActive(index)}
69
+ onPointerLeave={() => setActive(null)}
70
+ onFocus={() => setActive(index)}
71
+ onBlur={() => setActive(null)}
72
+ >
73
+ {active === index ? (
74
+ <span className="os-chart-tip" data-align={align(index)} role="presentation">
75
+ <span className="os-chart-tip-title">{day.label}</span>
76
+ {details.map((detail) => (
77
+ <span key={detail} className="os-chart-tip-row">
78
+ {detail}
79
+ </span>
80
+ ))}
81
+ </span>
82
+ ) : null}
83
+ </span>
84
+ );
85
+ })}
86
+ </div>
87
+ {startLabel || endLabel || summary ? (
88
+ <div className="os-uptime-axis">
89
+ <span>{startLabel}</span>
90
+ <span className="os-uptime-summary">{summary}</span>
91
+ <span>{endLabel}</span>
92
+ </div>
93
+ ) : null}
94
+ </div>
95
+ );
96
+ }
@@ -184,6 +184,8 @@ export { FunnelChart } from "./FunnelChart";
184
184
  export type { FunnelStage } from "./FunnelChart";
185
185
  export { HeatmapCalendar } from "./HeatmapCalendar";
186
186
  export type { HeatmapDay } from "./HeatmapCalendar";
187
+ export { UptimeBar } from "./UptimeBar";
188
+ export type { UptimeDay, UptimeTone } from "./UptimeBar";
187
189
  export { ChartLegend, CHART_TONES } from "./chartParts";
188
190
  export type { ChartTone, LegendItem } from "./chartParts";
189
191
  export { compactNumber } from "./chartMath";
@@ -191,5 +193,9 @@ export { compactNumber } from "./chartMath";
191
193
  // Mail — one line per message, for inboxes and reply feeds.
192
194
  export { MessageList } from "./MessageList";
193
195
  export type { MessageListItem } from "./MessageList";
196
+ // Consent — a bottom-of-viewport question that waits for an answer (cookies, terms).
197
+ export { ConsentBanner } from "./ConsentBanner";
198
+ export type { ConsentBannerProps } from "./ConsentBanner";
199
+
194
200
  export { PageTabs } from "./PageTabs";
195
201
  export type { PageTab } from "./PageTabs";
@@ -86,6 +86,11 @@
86
86
  .os-brand-footer-start {
87
87
  justify-content: flex-start;
88
88
  }
89
+ .os-brand-footer-extra {
90
+ display: flex;
91
+ flex-basis: 100%;
92
+ justify-content: inherit;
93
+ }
89
94
  .os-brand-footer-link {
90
95
  display: inline-flex;
91
96
  align-items: center;
@@ -1,13 +1,13 @@
1
1
  /* SegmentBar — a proportion bar coloured from the theme's data tokens. */
2
2
 
3
- .os-segments {
3
+ .os-segment-bar {
4
4
  display: flex;
5
5
  width: 100%;
6
6
  flex-direction: column;
7
7
  gap: var(--spacing-3);
8
8
  }
9
9
 
10
- .os-segment-track {
10
+ .os-segment-bar-track {
11
11
  display: flex;
12
12
  width: 100%;
13
13
  gap: var(--spacing-0-5);
@@ -17,35 +17,35 @@
17
17
  background: var(--color-track);
18
18
  }
19
19
 
20
- .os-segment {
20
+ .os-segment-bar-fill {
21
21
  min-width: var(--spacing-1);
22
22
  height: 100%;
23
23
  border-radius: var(--radius-full);
24
24
  transition: width var(--duration-slow) var(--ease-standard);
25
25
  }
26
26
 
27
- .os-segment[data-tone="blue"],
28
- .os-segment-key[data-tone="blue"] {
27
+ .os-segment-bar-fill[data-tone="blue"],
28
+ .os-segment-bar-key[data-tone="blue"] {
29
29
  background: var(--color-data-blue-3);
30
30
  }
31
- .os-segment[data-tone="green"],
32
- .os-segment-key[data-tone="green"] {
31
+ .os-segment-bar-fill[data-tone="green"],
32
+ .os-segment-bar-key[data-tone="green"] {
33
33
  background: var(--color-data-categorical-green);
34
34
  }
35
- .os-segment[data-tone="orange"],
36
- .os-segment-key[data-tone="orange"] {
35
+ .os-segment-bar-fill[data-tone="orange"],
36
+ .os-segment-bar-key[data-tone="orange"] {
37
37
  background: var(--color-data-categorical-orange);
38
38
  }
39
- .os-segment[data-tone="red"],
40
- .os-segment-key[data-tone="red"] {
39
+ .os-segment-bar-fill[data-tone="red"],
40
+ .os-segment-bar-key[data-tone="red"] {
41
41
  background: var(--color-data-categorical-red);
42
42
  }
43
- .os-segment[data-tone="neutral"],
44
- .os-segment-key[data-tone="neutral"] {
43
+ .os-segment-bar-fill[data-tone="neutral"],
44
+ .os-segment-bar-key[data-tone="neutral"] {
45
45
  background: var(--color-data-neutral);
46
46
  }
47
47
 
48
- .os-segment-legend {
48
+ .os-segment-bar-legend {
49
49
  display: flex;
50
50
  flex-wrap: wrap;
51
51
  gap: var(--spacing-1) var(--spacing-4);
@@ -56,25 +56,25 @@
56
56
  font-size: var(--font-size-sm);
57
57
  }
58
58
 
59
- .os-segment-legend li {
59
+ .os-segment-bar-legend li {
60
60
  display: inline-flex;
61
61
  align-items: center;
62
62
  gap: var(--spacing-1-5);
63
63
  }
64
64
 
65
- .os-segment-legend strong {
65
+ .os-segment-bar-legend strong {
66
66
  color: var(--color-text-primary);
67
67
  font-weight: var(--font-weight-semibold);
68
68
  }
69
69
 
70
- .os-segment-key {
70
+ .os-segment-bar-key {
71
71
  width: var(--spacing-2);
72
72
  height: var(--spacing-2);
73
73
  border-radius: var(--radius-full);
74
74
  }
75
75
 
76
76
  @media (prefers-reduced-motion: reduce) {
77
- .os-segment {
77
+ .os-segment-bar-fill {
78
78
  transition: none;
79
79
  }
80
80
  }
@@ -111,8 +111,8 @@
111
111
  [data-tone="neutral"] {
112
112
  --os-chart-tone: var(--color-data-neutral);
113
113
  }
114
- .os-segment[data-tone="purple"],
115
- .os-segment-key[data-tone="purple"] {
114
+ .os-segment-bar-fill[data-tone="purple"],
115
+ .os-segment-bar-key[data-tone="purple"] {
116
116
  background: var(--color-data-categorical-purple);
117
117
  }
118
118
 
@@ -657,3 +657,66 @@
657
657
  transition: none;
658
658
  }
659
659
  }
660
+
661
+ /* ── UptimeBar ────────────────────────────────────────────────────── */
662
+
663
+ .os-uptime {
664
+ display: flex;
665
+ min-width: 0;
666
+ flex-direction: column;
667
+ gap: var(--spacing-2);
668
+ }
669
+
670
+ .os-uptime-days {
671
+ display: flex;
672
+ height: var(--spacing-8);
673
+ gap: var(--spacing-0-5);
674
+ }
675
+
676
+ .os-uptime-day {
677
+ position: relative;
678
+ flex: 1 1 0;
679
+ min-width: 0;
680
+ border-radius: var(--radius-inner);
681
+ background: var(--color-data-categorical-green);
682
+ outline: none;
683
+ }
684
+ .os-uptime-day[data-tone="degraded"] {
685
+ background: var(--color-data-categorical-orange);
686
+ }
687
+ .os-uptime-day[data-tone="down"] {
688
+ background: var(--color-data-categorical-red);
689
+ }
690
+ .os-uptime-day[data-tone="none"] {
691
+ background: var(--color-background-muted);
692
+ }
693
+ .os-uptime-day:hover,
694
+ .os-uptime-day:focus-visible {
695
+ box-shadow: 0 0 0 var(--spacing-0-5) var(--color-border-emphasized);
696
+ }
697
+
698
+ /* The tip sits above the hovered day, pulled inward at either end of the row. */
699
+ .os-uptime-day .os-chart-tip {
700
+ inset: auto auto calc(100% + var(--spacing-2)) 50%;
701
+ transform: translateX(-50%);
702
+ }
703
+ .os-uptime-day .os-chart-tip[data-align="start"] {
704
+ left: 0;
705
+ transform: none;
706
+ }
707
+ .os-uptime-day .os-chart-tip[data-align="end"] {
708
+ right: 0;
709
+ left: auto;
710
+ transform: none;
711
+ }
712
+
713
+ .os-uptime-axis {
714
+ display: grid;
715
+ grid-template-columns: 1fr auto 1fr;
716
+ gap: var(--spacing-2);
717
+ color: var(--color-text-secondary);
718
+ font-size: var(--font-size-xs);
719
+ }
720
+ .os-uptime-axis span:last-child {
721
+ text-align: end;
722
+ }
@@ -0,0 +1,85 @@
1
+ /* ConsentBanner — a card-width question at the bottom of the viewport.
2
+ Above page content, below dialogs and notifications. */
3
+
4
+ .os-consent {
5
+ display: flex;
6
+ flex-wrap: wrap;
7
+ align-items: flex-end;
8
+ justify-content: space-between;
9
+ gap: var(--spacing-4);
10
+ box-sizing: border-box;
11
+ padding: var(--spacing-5);
12
+ background: var(--color-background-popover);
13
+ color: var(--color-text-primary);
14
+ border-radius: var(--radius-container);
15
+ box-shadow:
16
+ 0 0 0 1px var(--color-border),
17
+ var(--shadow-high);
18
+ }
19
+
20
+ .os-consent-fixed {
21
+ position: fixed;
22
+ z-index: 400;
23
+ inset-inline: var(--spacing-4);
24
+ bottom: calc(var(--spacing-4) + env(safe-area-inset-bottom));
25
+ max-width: var(--os-consent-width, 560px);
26
+ animation: os-consent-in var(--duration-slow) var(--ease-standard);
27
+ }
28
+
29
+ .os-consent-body {
30
+ display: flex;
31
+ flex: 1 1 280px;
32
+ flex-direction: column;
33
+ gap: var(--spacing-1);
34
+ min-width: 0;
35
+ }
36
+
37
+ .os-consent-title {
38
+ margin: 0;
39
+ font-size: var(--font-size-base);
40
+ font-weight: var(--font-weight-semibold);
41
+ line-height: var(--text-label-leading);
42
+ }
43
+
44
+ .os-consent-description {
45
+ margin: 0;
46
+ color: var(--color-text-secondary);
47
+ font-size: var(--font-size-sm);
48
+ }
49
+
50
+ .os-consent-link {
51
+ font-size: var(--font-size-sm);
52
+ }
53
+
54
+ .os-consent-actions {
55
+ display: flex;
56
+ flex-wrap: wrap;
57
+ gap: var(--spacing-2);
58
+ }
59
+
60
+ /* Phones: an edge-to-edge sheet with full-width choices. */
61
+ @media (max-width: 480px) {
62
+ .os-consent-fixed {
63
+ inset-inline: var(--spacing-2);
64
+ bottom: calc(var(--spacing-2) + env(safe-area-inset-bottom));
65
+ }
66
+ .os-consent-actions {
67
+ flex: 1 1 100%;
68
+ }
69
+ .os-consent-actions > * {
70
+ flex: 1 1 0;
71
+ }
72
+ }
73
+
74
+ @keyframes os-consent-in {
75
+ from {
76
+ opacity: 0;
77
+ transform: translateY(var(--spacing-4));
78
+ }
79
+ }
80
+
81
+ @media (prefers-reduced-motion: reduce) {
82
+ .os-consent-fixed {
83
+ animation: none;
84
+ }
85
+ }
@@ -0,0 +1,71 @@
1
+ /* Sideways scroll rows — filter chips, tab strips, kanban boards. The edge with
2
+ more content past it fades out, so a clipped item reads as "scroll for more"
3
+ instead of a cut-off label. Driven by the scroll position itself, so a row
4
+ that fits shows no fade. Browsers without scroll timelines keep a plain row. */
5
+
6
+ @property --os-scroll-fade-start {
7
+ syntax: "<length>";
8
+ inherits: false;
9
+ initial-value: 0px;
10
+ }
11
+
12
+ @property --os-scroll-fade-end {
13
+ syntax: "<length>";
14
+ inherits: false;
15
+ initial-value: 0px;
16
+ }
17
+
18
+ @supports (animation-timeline: scroll()) {
19
+ .astryx-scrollable-area[data-axis="inline"] {
20
+ --os-scroll-fade-size: var(--spacing-10);
21
+ mask-image: linear-gradient(
22
+ to right,
23
+ transparent,
24
+ black var(--os-scroll-fade-start),
25
+ black calc(100% - var(--os-scroll-fade-end)),
26
+ transparent
27
+ );
28
+ animation: os-scroll-fade linear both;
29
+ animation-timeline: scroll(self inline);
30
+ }
31
+
32
+ [dir="rtl"] .astryx-scrollable-area[data-axis="inline"] {
33
+ animation-name: os-scroll-fade-rtl;
34
+ }
35
+ }
36
+
37
+ /* Each edge reaches full fade within the first and last few percent of travel. */
38
+ @keyframes os-scroll-fade {
39
+ 0% {
40
+ --os-scroll-fade-start: 0px;
41
+ --os-scroll-fade-end: var(--os-scroll-fade-size);
42
+ }
43
+ 8% {
44
+ --os-scroll-fade-start: var(--os-scroll-fade-size);
45
+ }
46
+ 92% {
47
+ --os-scroll-fade-end: var(--os-scroll-fade-size);
48
+ }
49
+ 100% {
50
+ --os-scroll-fade-start: var(--os-scroll-fade-size);
51
+ --os-scroll-fade-end: 0px;
52
+ }
53
+ }
54
+
55
+ /* Right-to-left rows start at the right edge, so the gradient's ends swap. */
56
+ @keyframes os-scroll-fade-rtl {
57
+ 0% {
58
+ --os-scroll-fade-end: 0px;
59
+ --os-scroll-fade-start: var(--os-scroll-fade-size);
60
+ }
61
+ 8% {
62
+ --os-scroll-fade-end: var(--os-scroll-fade-size);
63
+ }
64
+ 92% {
65
+ --os-scroll-fade-start: var(--os-scroll-fade-size);
66
+ }
67
+ 100% {
68
+ --os-scroll-fade-end: var(--os-scroll-fade-size);
69
+ --os-scroll-fade-start: 0px;
70
+ }
71
+ }
@@ -18,6 +18,8 @@
18
18
  @import "./components/code-input.css";
19
19
  @import "./components/brand.css";
20
20
  @import "./components/google.css";
21
+ @import "./components/scroll.css";
22
+ @import "./components/consent.css";
21
23
 
22
24
  /* Input / Field */
23
25
  .os-field-group {
@@ -108,6 +110,16 @@
108
110
  border-block-start-color: var(--color-error);
109
111
  }
110
112
 
113
+ /* Columns settle on the board edge after a sideways scroll, so the last
114
+ visible column is whole or clearly past the fade, never sliced mid-card. */
115
+ .os-kanban-scroller {
116
+ scroll-snap-type: inline proximity;
117
+ }
118
+
119
+ .os-kanban-row > .os-kanban-cell {
120
+ scroll-snap-align: start;
121
+ }
122
+
111
123
  .os-kanban-cell [role="list"] {
112
124
  min-height: inherit;
113
125
  }
@@ -328,6 +340,17 @@ button.os-star {
328
340
  height: var(--size-element-lg);
329
341
  }
330
342
 
343
+ /* Disabled actions go neutral instead of a faded fill. Half-opacity accent read
344
+ as a lighter, still-clickable blue. A loading button keeps its color: it is
345
+ busy, not unavailable. */
346
+ .astryx-button[data-variant="primary"]:is(:disabled, [aria-disabled="true"]):not(
347
+ [aria-busy="true"]
348
+ ) {
349
+ background-color: var(--color-neutral);
350
+ color: var(--color-text-disabled);
351
+ opacity: 1;
352
+ }
353
+
331
354
  .astryx-button:has(> span > span:only-child svg) {
332
355
  padding-inline: 0;
333
356
  padding-block: 0;
@@ -364,6 +387,29 @@ button.os-star {
364
387
  border-radius: var(--radius-full);
365
388
  }
366
389
 
390
+ /* A text column beside other content shrinks before its siblings do. Without
391
+ this, a one-line truncated label (nowrap) holds the column at its full text
392
+ width and pushes the buttons next to it out of the card. */
393
+ .astryx-stack[data-direction="horizontal"] > .astryx-stack[data-direction="vertical"] {
394
+ min-width: 0;
395
+ }
396
+
397
+ /* Rounded avatars hold logos. Every mark sits whole on the same light tile with
398
+ a hairline edge, so a transparent PNG, a full-bleed square, and a small file
399
+ all read as one set. Logos are drawn for light grounds, so the tile stays light
400
+ in dark mode too (the soft off-white of dark-mode text). Initials keep their fill. */
401
+ .astryx-avatar[data-shape="rounded"]:has(img) {
402
+ background-color: light-dark(var(--color-background-surface), var(--color-text-primary));
403
+ outline: 1px solid var(--color-border);
404
+ outline-offset: -1px;
405
+ }
406
+
407
+ .astryx-avatar[data-shape="rounded"] img {
408
+ box-sizing: border-box;
409
+ padding: 8%;
410
+ object-fit: contain;
411
+ }
412
+
367
413
  /* Pressed toggles stay outlined. A weak accent wash sits behind the accent
368
414
  icon and label, so on reads as color rather than a solid fill. */
369
415
  .astryx-toggle-button[data-is-pressed="true"] {