@kolkrabbi/kol-framework 0.8.1 → 0.9.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-framework",
3
- "version": "0.8.1",
3
+ "version": "0.9.1",
4
4
  "description": "KOL app shell — sidenav, layout, theme toggle, footer, heroes + brand color layer. SideNav takes nav data as props so the tree stays app-local.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,7 +27,7 @@
27
27
  "app-shell"
28
28
  ],
29
29
  "dependencies": {
30
- "@kolkrabbi/kol-component": "0.14.1",
30
+ "@kolkrabbi/kol-component": "0.14.2",
31
31
  "@kolkrabbi/kol-icons": "0.8.11"
32
32
  },
33
33
  "peerDependencies": {
@@ -12,7 +12,10 @@ export default function PageSection({ id, label, title, body, children, classNam
12
12
  <section id={id} className={cls}>
13
13
  {divider && <Divider className="kol-page-section-divider" />}
14
14
  {hasHead && (
15
- <header className={fullbleed ? 'max-w-[960px]' : 'max-w-[720px]'}>
15
+ /* head widths read the scale instead of transcribing it. `960px` was
16
+ * exactly the panel token; `720px` was an improvised near-miss and
17
+ * becomes the column token — 768px, so section heads widen by 48px. */
18
+ <header className={fullbleed ? 'max-w-[var(--kol-content-panel)]' : 'max-w-[var(--kol-content-column)]'}>
16
19
  {label && <p className="kol-prose-label">{label}</p>}
17
20
  {title && <h2 className="kol-prose-title">{title}</h2>}
18
21
  {body && <p className="kol-prose-lede">{body}</p>}
@@ -78,9 +78,11 @@ export default function ShellHeader({
78
78
  {showThemeToggle && (
79
79
  <Tooltip label="Toggle theme">
80
80
  {/* one rung up at lg+ — the bar has room, and the toggle is
81
- * the most-used control in the row */}
82
- <span className="hidden lg:inline-flex"><ThemeToggle size="lg" /></span>
83
- <span className="lg:hidden"><ThemeToggle /></span>
81
+ * the most-used control in the row. New spec API (0.9.0):
82
+ * quiet square = button geometry + fill none + no label
83
+ * (was the old `icon` variant default). */}
84
+ <span className="hidden lg:inline-flex"><ThemeToggle fill="none" label={false} size="lg" /></span>
85
+ <span className="lg:hidden"><ThemeToggle fill="none" label={false} /></span>
84
86
  </Tooltip>
85
87
  )}
86
88
  {onMenuClick && (
@@ -2,121 +2,126 @@ import { Icon } from '@kolkrabbi/kol-icons'
2
2
  import { useTheme } from './theme.js'
3
3
 
4
4
  /**
5
- * Theme toggle — horizontal icon-swap button, tri-state (0.6.0).
5
+ * Theme toggle — tri-state glyph-roll button (0.9.0, the approved 2-variant spec).
6
6
  *
7
7
  * Clicking CYCLES light → dark → system → light. 'system' is the no-choice
8
8
  * state: the stamp + saved key are cleared and the page follows
9
- * prefers-color-scheme live (USER law: explicit choice > system/auto > light).
10
- * The glyph shows the current mode — the split-circle `mode-toggle-01` for
11
- * light/dark (rolling + spinning exactly as before), the v1 `desktop` glyph
12
- * for system.
9
+ * prefers-color-scheme live (USER law: explicit choice > system > light).
13
10
  *
14
- * Variants:
15
- * icon — icon-only button on the DS button ladder: `size` 'sm'|'md'|'lg'
16
- * → 14/16/18px glyph, square box + quiet states from the
17
- * .kol-btn-nav chrome (theme ≥0.11.4), so the toggle sits flush
18
- * with sibling navigation buttons in a bar.
19
- * button — a REAL ladder button (2026-07-29, user-ordered): glyph + label
20
- * inside plain `.kol-btn .kol-btn-primary .kol-btn-{size}` with
21
- * the rung's mono type and pairing-law glyph (16/20/24) — no
22
- * width/justify overrides, so it measures exactly like any
23
- * other kol-btn on the rung.
24
- * hop — full-width labeled Button-primary-styled sidenav row
25
- * (bg-fg-04, on-primary text). For sidenav rows where it pairs
26
- * with other Button-primary "hop" entries.
27
- * hop-bare — same shape + padding as hop, but fully transparent (no rest
28
- * bg, no hover bg). For sidenav rows where the row should read
29
- * as plain text + icon, not a button.
11
+ * ONE glyph, ever (user ruling 2026-07-30): every position in the roll strip
12
+ * is `mode-toggle-01`, the split circle — the system state wears it too, told
13
+ * apart by the label / tooltip, never by a third glyph. The strip rolls like
14
+ * a coin: rotation ACCUMULATES −180° per slot on the same 500ms clock as the
15
+ * travel, so one lap shows both directions.
30
16
  *
31
- * Theme state lives in useTheme (./theme.js) — the exported machinery
32
- * (initial-state resolution, saved-choice re-stamp, system-follow, cross-
33
- * instance sync). This component is only the glyph-roll UI on top of it.
17
+ * THE SPEC (approved 2026-07-30 — variants are container GEOMETRY only;
18
+ * everything else is a prop):
19
+ *
20
+ * variant "button" — padded rung + corner radius (THE button container)
21
+ * fill 'subtle' | 'none' the one bg element — subtle = grey fill
22
+ * (brand sidebar) · none = the invisible
23
+ * container (nav-bar chrome)
24
+ * iconRight bool glyph right of the text (left default)
25
+ * label bool text on/off — off pins the square box
26
+ * per rung (28/32/36; icon-only is a
27
+ * geometry condition, not a variant)
28
+ * fullWidth bool stretches to a sidenav row
29
+ * size 'sm' | 'md' | 'lg' moves pad + text + glyph together
30
+ *
31
+ * variant "flush" — no pad, no radius, no fill, ever
32
+ * same prop list minus `fill`; no square pinning (there is no box);
33
+ * size scales text + glyph only.
34
+ *
35
+ * Glyph law: text-adjacent 14/16/18 (stays inside the rung line box);
36
+ * solo 16/20/24 (the pinned-square pairing).
37
+ *
38
+ * DEPRECATED aliases (0.6.x variants) — render their old chrome verbatim so
39
+ * no consumer shifts a pixel; migrate at your own pace:
40
+ * icon → button + fill none + label off
41
+ * hop → button + fill subtle + fullWidth (old: chrome pinned md)
42
+ * hop-bare → flush + fullWidth (old: 6/24 padding kept)
43
+ *
44
+ * Theme state lives in useTheme (./theme.js); this component is only the
45
+ * glyph-roll UI on top of it.
34
46
  */
35
47
  const MODE_LABEL = { light: 'Light mode', dark: 'Dark mode', system: 'System' }
36
48
  const NEXT_MODE = { light: 'dark', dark: 'system', system: 'light' }
49
+ const SLOT = { dark: 0, light: 1, system: 2 }
37
50
 
38
- export default function ThemeToggle({ variant = 'icon', size = 'md', className = '' }) {
51
+ export default function ThemeToggle({
52
+ variant = 'button',
53
+ fill = 'subtle',
54
+ size = 'md',
55
+ label = true,
56
+ iconRight = false,
57
+ fullWidth = false,
58
+ className = '',
59
+ }) {
39
60
  const { mode, cycle } = useTheme()
40
61
 
41
62
  const next = NEXT_MODE[mode]
42
63
  const nextLabel = next === 'system' ? 'system theme' : `${next} mode`
43
- const handleToggle = cycle
44
64
 
45
- /* The ROLL (redone 2026-07-30 — the 0.5.13 motion was declared FAILED).
46
- * Wheel mechanics, not slide+flip: rotation ACCUMULATES with the slot
47
- * (-180° per glyph-width) and runs on the same 500ms/ease clock as the
48
- * strip's translateX, so the split-circle reads as a coin rolling to its
49
- * next position — travel left = CCW, travel right = CW, two slots = two
50
- * full half-turns. The old code rotated on `isDark` alone, desynced from
51
- * the travel, which is exactly why it read as a slide with a flip bolted
52
- * on. The desktop glyph doesn't spin — it's not a wheel; it slides in as
53
- * the no-choice state. */
54
- const slot = mode === 'dark' ? 0 : mode === 'light' ? 1 : 2
55
- const glyphRoll = {
56
- transform: `rotate(${slot * -180}deg)`,
57
- lineHeight: 0,
58
- }
59
- const iconSwap = (size) => (
65
+ /* The ROLL — wheel mechanics (2026-07-30, one-glyph ruling): three copies
66
+ * of the split circle; rotation accumulates with the slot (−180° per
67
+ * glyph-width) on the same 500ms/ease clock as the strip's translateX.
68
+ * light→dark rolls one way, dark→system rolls back two half-turns,
69
+ * system→light forward again — both directions in one lap. */
70
+ const slot = SLOT[mode]
71
+ const iconSwap = (px) => (
60
72
  <span
61
73
  className="relative inline-block overflow-hidden"
62
- style={{ width: size, height: size }}
74
+ style={{ width: px, height: px }}
63
75
  aria-hidden="true"
64
76
  >
65
77
  <span
66
78
  className="flex transition-transform duration-500 ease-in-out"
67
- style={{ width: size * 3, transform: `translateX(-${slot * size}px)` }}
79
+ style={{ width: px * 3, transform: `translateX(-${slot * px}px)` }}
68
80
  >
69
- <span className="inline-flex transition-transform duration-500 ease-in-out" style={glyphRoll}>
70
- <Icon name="mode-toggle-01" size={size} />
71
- </span>
72
- <span className="inline-flex transition-transform duration-500 ease-in-out" style={glyphRoll}>
73
- <Icon name="mode-toggle-01" size={size} />
74
- </span>
75
- <span className="inline-flex" style={{ lineHeight: 0 }}>
76
- <Icon name="desktop" size={size} />
77
- </span>
81
+ {[0, 1, 2].map((i) => (
82
+ <span
83
+ key={i}
84
+ className="inline-flex transition-transform duration-500 ease-in-out"
85
+ style={{ transform: `rotate(${slot * -180}deg)`, lineHeight: 0 }}
86
+ >
87
+ <Icon name="mode-toggle-01" size={px} />
88
+ </span>
89
+ ))}
78
90
  </span>
79
91
  </span>
80
92
  )
81
93
 
82
- if (variant === 'button') {
83
- const mono = size === 'sm' ? 'kol-mono-12' : size === 'lg' ? 'kol-mono-16' : 'kol-mono-14'
84
- /* Text-adjacent glyph law: the glyph must never exceed the rung's mono
85
- * line box (16/18/22), or it inflates the button past the ladder height —
86
- * playwright-measured against a same-rung kol-btn: 14/16/18 keeps them
87
- * pixel-equal. (The 16/20/24 pairing law is for pinned icon-only squares.) */
88
- const glyph = size === 'sm' ? 14 : size === 'lg' ? 18 : 16
94
+ const mono = size === 'sm' ? 'kol-mono-12' : size === 'lg' ? 'kol-mono-16' : 'kol-mono-14'
95
+ /* Glyph law: text-adjacent stays inside the rung line box; solo takes the
96
+ * pinned-square pairing sizes. */
97
+ const glyphWithText = size === 'sm' ? 14 : size === 'lg' ? 18 : 16
98
+ const glyphSolo = size === 'sm' ? 16 : size === 'lg' ? 24 : 20
99
+
100
+ const shared = {
101
+ type: 'button',
102
+ onClick: cycle,
103
+ 'aria-label': `Switch to ${nextLabel}`,
104
+ title: `Switch to ${nextLabel}`,
105
+ }
106
+
107
+ /* ── DEPRECATED aliases — old chrome verbatim (0.6.x) ── */
108
+ if (variant === 'icon') {
89
109
  return (
90
- <button
91
- type="button"
92
- onClick={handleToggle}
93
- aria-label={`Switch to ${nextLabel}`}
94
- className={`kol-btn kol-btn-primary kol-btn-${size} ${mono} gap-2 ${className}`.trim()}
95
- >
96
- {iconSwap(glyph)}
97
- <span>{MODE_LABEL[mode]}</span>
110
+ <button {...shared} className={`kol-btn kol-btn-nav kol-btn-${size} kol-btn-icon ${className}`.trim()}>
111
+ {iconSwap(glyphSolo)}
98
112
  </button>
99
113
  )
100
114
  }
101
-
102
115
  if (variant === 'hop' || variant === 'hop-bare') {
103
116
  const bare = variant === 'hop-bare'
104
117
  const chromeCls = bare
105
118
  ? 'w-full inline-flex items-center justify-start gap-2 py-1.5 px-6 kol-mono-14 bg-transparent text-emphasis transition-colors'
106
119
  : 'kol-btn kol-btn-primary kol-btn-md kol-mono-14 w-full justify-start gap-2'
107
120
  return (
108
- <button
109
- type="button"
110
- onClick={handleToggle}
111
- aria-label={`Switch to ${nextLabel}`}
112
- className={`${chromeCls} ${className}`.trim()}
113
- >
121
+ <button {...shared} className={`${chromeCls} ${className}`.trim()}>
114
122
  <span className="inline-flex items-center justify-center shrink-0" aria-hidden="true">
115
- {iconSwap(size === "sm" ? 16 : size === "lg" ? 24 : 20)}
123
+ {iconSwap(glyphSolo)}
116
124
  </span>
117
- {/* kol-sidenav-hop-label keeps the responsive label-hide rule firing
118
- * at narrow viewports without subjecting the button to the muted
119
- * sidenav-hop text-color override. */}
120
125
  <span className="kol-sidenav-hop-label flex-1 min-w-0 text-left">
121
126
  {MODE_LABEL[mode]}
122
127
  </span>
@@ -124,20 +129,38 @@ export default function ThemeToggle({ variant = 'icon', size = 'md', className =
124
129
  )
125
130
  }
126
131
 
127
- // variant === 'icon' (default) — geometry from the DS button ladder
128
- // (sm 16 / md 20 / lg 24 glyph — 2026-07-28 pairing law), chrome = ghost icon-only .kol-btn.
129
- // Overrules the 2026-07-15 "36×36/20 reference" one-off (user ruling
130
- // 2026-07-28: the button ladder is the reference, bar icons included).
131
- const glyph = size === 'sm' ? 16 : size === 'lg' ? 24 : 20
132
+ /* ── THE SPEC ── */
133
+ const width = fullWidth ? 'w-full justify-start' : ''
134
+
135
+ if (variant === 'flush') {
136
+ const glyph = label ? glyphWithText : glyphSolo
137
+ const cls = `inline-flex items-center gap-2 ${mono} bg-transparent text-emphasis transition-colors ${width} ${className}`
138
+ return (
139
+ <button {...shared} className={cls.replace(/\s+/g, ' ').trim()}>
140
+ {!iconRight && iconSwap(glyph)}
141
+ {label && <span className={fullWidth ? 'flex-1 min-w-0 text-left' : ''}>{MODE_LABEL[mode]}</span>}
142
+ {iconRight && iconSwap(glyph)}
143
+ </button>
144
+ )
145
+ }
146
+
147
+ /* variant === 'button' (default) — the padded rung. fill is the one bg
148
+ * element: subtle = the grey fill (kol-btn-primary), none = the invisible
149
+ * container (kol-btn-nav — quiet ink + hover wash, geometry identical). */
150
+ const fillCls = fill === 'none' ? 'kol-btn-nav' : 'kol-btn-primary'
151
+ if (!label) {
152
+ // icon-only pins the square box per rung — geometry condition, not a variant
153
+ return (
154
+ <button {...shared} className={`kol-btn ${fillCls} kol-btn-${size} kol-btn-icon ${width} ${className}`.replace(/\s+/g, ' ').trim()}>
155
+ {iconSwap(glyphSolo)}
156
+ </button>
157
+ )
158
+ }
132
159
  return (
133
- <button
134
- type="button"
135
- onClick={handleToggle}
136
- aria-label={`Switch to ${nextLabel}`}
137
- title={`Switch to ${nextLabel}`}
138
- className={`kol-btn kol-btn-nav kol-btn-${size} kol-btn-icon ${className}`.trim()}
139
- >
140
- {iconSwap(glyph)}
160
+ <button {...shared} className={`kol-btn ${fillCls} kol-btn-${size} ${mono} gap-2 ${width} ${className}`.replace(/\s+/g, ' ').trim()}>
161
+ {!iconRight && iconSwap(glyphWithText)}
162
+ <span className={fullWidth ? 'flex-1 min-w-0 text-left' : ''}>{MODE_LABEL[mode]}</span>
163
+ {iconRight && iconSwap(glyphWithText)}
141
164
  </button>
142
165
  )
143
166
  }